Fix docs explorer cmd+click

This commit is contained in:
Gregory Schier
2025-07-15 07:02:08 -07:00
parent 0c60d190af
commit 2fcd2a3c07
7 changed files with 139 additions and 116 deletions

View File

@@ -38,7 +38,11 @@ export function HttpRequestLayout({ activeRequest, style }: Props) {
/>
);
if (activeRequest.bodyType === 'graphql' && showGraphQLDocExplorer && graphQLSchema != null) {
if (
activeRequest.bodyType === 'graphql' &&
showGraphQLDocExplorer[activeRequest.id] !== undefined &&
graphQLSchema != null
) {
return (
<SplitLayout
name="graphql_layout"
@@ -46,7 +50,7 @@ export function HttpRequestLayout({ activeRequest, style }: Props) {
firstSlot={requestResponseSplit}
secondSlot={({ style, orientation }) => (
<GraphQLDocsExplorer
key={activeRequest.id}
requestId={activeRequest.id}
schema={graphQLSchema}
className={classNames(orientation == 'horizontal' && '!ml-0')}
style={style}

View File

@@ -37,9 +37,11 @@ import {
import { tags as t } from '@lezer/highlight';
import type { EnvironmentVariable } from '@yaakapp-internal/models';
import { graphql } from 'cm6-graphql';
import { activeRequestIdAtom } from '../../../hooks/useActiveRequestId';
import { jotaiStore } from '../../../lib/jotai';
import { renderMarkdown } from '../../../lib/markdown';
import { pluralizeCount } from '../../../lib/pluralize';
import { showInGraphQLDocsExplorer } from '../../graphql/useGraphQLDocsExplorer';
import { showGraphQLDocExplorerAtom } from '../../graphql/graphqlAtoms';
import type { EditorProps } from './Editor';
import { pairs } from './pairs/extension';
import { text } from './text/extension';
@@ -135,8 +137,13 @@ export function getLanguageExtension({
return span;
},
onShowInDocs(field, type, parentType) {
console.log("SHOW IN DOCS", field);
showInGraphQLDocsExplorer(field, type, parentType).catch(console.error);
const activeRequestId = jotaiStore.get(activeRequestIdAtom);
if (activeRequestId == null) return;
console.log('SHOW IN DOCS', field);
jotaiStore.set(showGraphQLDocExplorerAtom, (v) => ({
...v,
[activeRequestId]: { field, type, parentType },
}));
},
}),
extraExtensions,

View File

@@ -21,24 +21,26 @@ import {
isScalarType,
isUnionType,
} from 'graphql';
import { useAtomValue } from 'jotai';
import type { CSSProperties, HTMLAttributes, KeyboardEvent, ReactNode } from 'react';
import { Fragment, memo, useCallback, useMemo, useRef, useState } 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';
import { useGraphQLDocsExplorerEvent } from './useGraphQLDocsExplorer';
interface Props {
style?: CSSProperties;
schema: GraphQLSchema;
requestId: string;
className?: string;
}
@@ -51,6 +53,7 @@ type ExplorerItem =
export const GraphQLDocsExplorer = memo(function GraphQLDocsExplorer({
style,
schema,
requestId,
className,
}: Props) {
const [activeItem, setActiveItem] = useState<ExplorerItem>(null);
@@ -58,17 +61,34 @@ export const GraphQLDocsExplorer = memo(function GraphQLDocsExplorer({
const qryType = schema.getQueryType();
const mutType = schema.getMutationType();
const subType = schema.getSubscriptionType();
const showField = useAtomValue(showGraphQLDocExplorerAtom)[requestId] ?? null;
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;
}
});
});
useEffect(() => {
if (showField === null) {
setActiveItem(null);
} else {
walkTypeGraph(schema, null, (t, from) => {
const isRootParentType =
showField.parentType === 'Query' ||
showField.parentType === 'Mutation' ||
showField.parentType === 'Subscription';
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)
) {
console.log('SET FIELD', t, from);
setActiveItem(toExplorerItem(t, toExplorerItem(from, null)));
return false;
} else if (showField.type === t.name && from?.name === showField.parentType) {
setActiveItem(toExplorerItem(t, toExplorerItem(from, null)));
return false;
} else {
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;
@@ -83,6 +103,9 @@ export const GraphQLDocsExplorer = memo(function GraphQLDocsExplorer({
<GraphQLExplorerHeader
containerHeight={containerSize.height}
item={activeItem}
onClose={() => {
jotaiStore.set(showGraphQLDocExplorerAtom, (v) => ({ ...v, [requestId]: undefined }));
}}
setItem={setActiveItem}
schema={schema}
/>
@@ -140,11 +163,13 @@ function GraphQLExplorerHeader({
item,
setItem,
schema,
onClose,
containerHeight,
}: {
item: ExplorerItem;
setItem: (t: ExplorerItem) => void;
schema: GraphQLSchema;
onClose: () => void;
containerHeight: number;
}) {
const findIt = (t: ExplorerItem): ExplorerItem[] => {
@@ -186,14 +211,7 @@ function GraphQLExplorerHeader({
/>
</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);
}}
/>
<IconButton icon="x" size="sm" title="Close documenation explorer" onClick={onClose} />
</div>
</nav>
);
@@ -219,6 +237,11 @@ function GqlTypeInfo({
<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>
);
@@ -294,6 +317,28 @@ function GqlTypeInfo({
))}
</div>
);
} else 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>
);
} else if (item.kind === 'field') {
return (
<div className="flex flex-col gap-3">
@@ -392,7 +437,7 @@ function GqlTypeInfo({
);
}
console.log('Unknown GraphQL Type', item.type, isNonNullType(item.type));
console.log('Unknown GraphQL Type', item);
return <div>Unknown GraphQL type</div>;
}
@@ -665,8 +710,7 @@ function GqlSchemaSearch({
} else {
results.push({ name: type.name, type, score: match.score, from, depth });
}
return true; // Continue searching
return true;
});
results.sort((a, b) => {
if (value == '') {
@@ -922,19 +966,25 @@ function walkTypeGraph(
}
}
function toExplorerItem(t: any, from: ExplorerItem | null): ExplorerItem {
function toExplorerItem(t: any, from: ExplorerItem | null): ExplorerItem | null {
if (t == null) return null;
// GraphQLField-like: has `args` and `type`
if (t && typeof t === 'object' && Array.isArray(t.args) && t.type) {
// 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` and maybe `defaultValue`, but no `args`
if (t && typeof t === 'object' && t.type && !('args' in t)) {
// 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 };
}
// Otherwise, assume GraphQLType (object, scalar, enum, etc.)
// Fallback: treat as GraphQLNamedType (object, scalar, enum, etc.)
return { kind: 'type', type: t, from };
}

View File

@@ -48,7 +48,8 @@ export function GraphQLEditor({ request, onChange, baseRequest, ...extraEditorPr
return { query: request.body.query ?? '', variables: request.body.variables ?? '' };
}, [extraEditorProps.forceUpdateKey]);
const [isDocOpen, setGraphqlDocStateAtomValue] = useAtom(showGraphQLDocExplorerAtom);
const [isDocOpenRecord, setGraphqlDocStateAtomValue] = useAtom(showGraphQLDocExplorerAtom);
const isDocOpen = isDocOpenRecord[request.id] !== undefined;
const handleChangeQuery = (query: string) => {
const newBody = { query, variables: currentBody.variables || undefined };
@@ -132,7 +133,10 @@ export function GraphQLEditor({ request, onChange, baseRequest, ...extraEditorPr
label: `${isDocOpen ? 'Hide' : 'Show'} Documentation`,
leftSlot: <Icon icon="book_open_text" />,
onSelect: () => {
setGraphqlDocStateAtomValue(!isDocOpen);
setGraphqlDocStateAtomValue((v) => ({
...v,
[request.id]: isDocOpen ? undefined : null,
}));
},
},
{
@@ -178,16 +182,17 @@ export function GraphQLEditor({ request, onChange, baseRequest, ...extraEditorPr
</div>,
],
[
schema,
clear,
error,
isDocOpen,
isLoading,
refetch,
error,
autoIntrospectDisabled,
baseRequest.id,
clear,
schema,
setAutoIntrospectDisabled,
isDocOpen,
setGraphqlDocStateAtomValue,
request.id,
setAutoIntrospectDisabled,
],
);

View File

@@ -1,3 +1,14 @@
import { atomWithKVStorage } from '../../lib/atoms/atomWithKVStorage';
export const showGraphQLDocExplorerAtom = atomWithKVStorage<boolean>('show_graphql_docs', false);
export const showGraphQLDocExplorerAtom = atomWithKVStorage<
Record<
string,
| {
type?: string;
field?: string;
parentType?: string;
}
| null
| undefined
>
>('show_graphql_docs', {});

View File

@@ -1,63 +0,0 @@
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);
}
})();