mirror of
https://github.com/graphql-hive/console
synced 2026-05-24 09:38:26 +00:00
Enhancement/lab query builder search (#7835)
This commit is contained in:
parent
5b7921d763
commit
7f58cb856b
9 changed files with 2829 additions and 331 deletions
6
.changeset/sweet-peas-bow.md
Normal file
6
.changeset/sweet-peas-bow.md
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
'@graphql-hive/laboratory': patch
|
||||
'@graphql-hive/render-laboratory': patch
|
||||
---
|
||||
|
||||
Enhancement: Implemented search field for query builder in new lab, with two modes: list and tree
|
||||
|
|
@ -38,6 +38,7 @@
|
|||
"zod": "^4.1.12"
|
||||
},
|
||||
"dependencies": {
|
||||
"radix-ui": "^1.4.3",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Fragment, useCallback, useDeferredValue, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
GraphQLEnumType,
|
||||
GraphQLObjectType,
|
||||
GraphQLScalarType,
|
||||
GraphQLSchema,
|
||||
GraphQLUnionType,
|
||||
OperationTypeNode,
|
||||
type GraphQLArgument,
|
||||
type GraphQLField,
|
||||
} from 'graphql';
|
||||
|
|
@ -14,11 +16,21 @@ import {
|
|||
CopyMinusIcon,
|
||||
CuboidIcon,
|
||||
FolderIcon,
|
||||
ListTreeIcon,
|
||||
RotateCcwIcon,
|
||||
SearchIcon,
|
||||
TextAlignStartIcon,
|
||||
} from 'lucide-react';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/laboratory/components/ui/toggle-group';
|
||||
import type { LaboratoryOperation } from '../../lib/operations';
|
||||
import { getOpenPaths, isArgInQuery, isPathInQuery } from '../../lib/operations.utils';
|
||||
import { cn } from '../../lib/utils';
|
||||
import {
|
||||
getFieldByPath,
|
||||
getOpenPaths,
|
||||
isArgInQuery,
|
||||
isPathInQuery,
|
||||
searchSchemaPaths,
|
||||
} from '../../lib/operations.utils';
|
||||
import { cn, splitIdentifier } from '../../lib/utils';
|
||||
import { GraphQLType } from '../graphql-type';
|
||||
import { GraphQLIcon } from '../icons';
|
||||
import { Button } from '../ui/button';
|
||||
|
|
@ -94,8 +106,14 @@ export const BuilderScalarField = (props: {
|
|||
path: string[];
|
||||
openPaths: string[];
|
||||
setOpenPaths: (openPaths: string[]) => void;
|
||||
visiblePaths?: Set<string> | null;
|
||||
forcedOpenPaths?: Set<string> | null;
|
||||
isSearchActive?: boolean;
|
||||
isReadOnly?: boolean;
|
||||
operation?: LaboratoryOperation | null;
|
||||
searchValue?: string;
|
||||
label?: React.ReactNode;
|
||||
disableChildren?: boolean;
|
||||
}) => {
|
||||
const { activeOperation, addPathToActiveOperation, deletePathFromActiveOperation, activeTab } =
|
||||
useLaboratory();
|
||||
|
|
@ -104,25 +122,23 @@ export const BuilderScalarField = (props: {
|
|||
return props.operation ?? activeOperation ?? null;
|
||||
}, [props.operation, activeOperation]);
|
||||
|
||||
const path = useMemo(() => {
|
||||
return props.path.join('.');
|
||||
}, [props.path]);
|
||||
|
||||
const isOpen = useMemo(() => {
|
||||
return props.openPaths.includes(props.path.join('.'));
|
||||
}, [props.openPaths, props.path]);
|
||||
return props.openPaths.includes(path) || !!props.forcedOpenPaths?.has(path);
|
||||
}, [props.openPaths, props.forcedOpenPaths, path]);
|
||||
|
||||
const setIsOpen = useCallback(
|
||||
(isOpen: boolean) => {
|
||||
props.setOpenPaths(
|
||||
isOpen
|
||||
? [...props.openPaths, props.path.join('.')]
|
||||
: props.openPaths.filter(path => path !== props.path.join('.')),
|
||||
isOpen ? [...props.openPaths, path] : props.openPaths.filter(openPath => openPath !== path),
|
||||
);
|
||||
},
|
||||
[props],
|
||||
[path, props],
|
||||
);
|
||||
|
||||
const path = useMemo(() => {
|
||||
return props.path.join('.');
|
||||
}, [props.path]);
|
||||
|
||||
const isInQuery = useMemo(() => {
|
||||
return isPathInQuery(operation?.query ?? '', path);
|
||||
}, [operation?.query, path]);
|
||||
|
|
@ -135,6 +151,61 @@ export const BuilderScalarField = (props: {
|
|||
return args.some(arg => isArgInQuery(operation?.query ?? '', path, arg.name));
|
||||
}, [operation?.query, args, path]);
|
||||
|
||||
const shouldHighlight = useMemo(() => {
|
||||
const splittedName = splitIdentifier(props.field.name);
|
||||
|
||||
return splittedName.some(p => props.searchValue?.toLowerCase().includes(p.toLowerCase()));
|
||||
}, [props.searchValue, props.field.name]);
|
||||
|
||||
if (props.isSearchActive && props.visiblePaths && !props.visiblePaths.has(path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (props.disableChildren) {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'text-muted-foreground bg-card p-1! group sticky top-0 z-10 w-full justify-start overflow-hidden text-xs',
|
||||
{
|
||||
'text-foreground-primary': isInQuery,
|
||||
},
|
||||
)}
|
||||
style={{
|
||||
top: `${(props.path.length - 2) * 32}px`,
|
||||
}}
|
||||
size="sm"
|
||||
>
|
||||
<div className="bg-card absolute left-0 top-0 -z-20 size-full" />
|
||||
<div className="group-hover:bg-accent/50 absolute left-0 top-0 -z-10 size-full transition-colors" />
|
||||
<Checkbox
|
||||
onClick={e => e.stopPropagation()}
|
||||
checked={isInQuery}
|
||||
disabled={activeTab?.type !== 'operation' || props.isReadOnly}
|
||||
onCheckedChange={checked => {
|
||||
if (checked) {
|
||||
setIsOpen(true);
|
||||
addPathToActiveOperation(path);
|
||||
} else {
|
||||
deletePathFromActiveOperation(path);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<BoxIcon className="size-4 text-rose-400" />
|
||||
{props.label ?? (
|
||||
<span
|
||||
className={cn({
|
||||
'text-primary-foreground bg-primary -mx-0.5 rounded-sm px-0.5': shouldHighlight,
|
||||
})}
|
||||
>
|
||||
{props.field.name}
|
||||
</span>
|
||||
)}
|
||||
: <GraphQLType type={props.field.type} />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (args.length > 0) {
|
||||
return (
|
||||
<Collapsible key={props.field.name} open={isOpen} onOpenChange={setIsOpen}>
|
||||
|
|
@ -173,7 +244,16 @@ export const BuilderScalarField = (props: {
|
|||
}}
|
||||
/>
|
||||
<BoxIcon className="size-4 text-rose-400" />
|
||||
{props.field.name}: <GraphQLType type={props.field.type} />
|
||||
{props.label ?? (
|
||||
<span
|
||||
className={cn({
|
||||
'text-primary-foreground bg-primary -mx-0.5 rounded-sm px-0.5': shouldHighlight,
|
||||
})}
|
||||
>
|
||||
{props.field.name}
|
||||
</span>
|
||||
)}
|
||||
: <GraphQLType type={props.field.type} />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="border-border relative z-0 ml-3 flex flex-col border-l pl-2">
|
||||
|
|
@ -248,7 +328,16 @@ export const BuilderScalarField = (props: {
|
|||
}}
|
||||
/>
|
||||
<BoxIcon className="size-4 text-rose-400" />
|
||||
{props.field.name}: <GraphQLType type={props.field.type} />
|
||||
{props.label ?? (
|
||||
<span
|
||||
className={cn({
|
||||
'text-primary-foreground bg-primary -mx-0.5 rounded-sm px-0.5': shouldHighlight,
|
||||
})}
|
||||
>
|
||||
{props.field.name}
|
||||
</span>
|
||||
)}
|
||||
: <GraphQLType type={props.field.type} />
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
|
@ -258,8 +347,14 @@ export const BuilderObjectField = (props: {
|
|||
path: string[];
|
||||
openPaths: string[];
|
||||
setOpenPaths: (openPaths: string[]) => void;
|
||||
visiblePaths?: Set<string> | null;
|
||||
forcedOpenPaths?: Set<string> | null;
|
||||
isSearchActive?: boolean;
|
||||
isReadOnly?: boolean;
|
||||
operation?: LaboratoryOperation | null;
|
||||
searchValue?: string;
|
||||
label?: React.ReactNode;
|
||||
disableChildren?: boolean;
|
||||
}) => {
|
||||
const {
|
||||
schema,
|
||||
|
|
@ -273,19 +368,21 @@ export const BuilderObjectField = (props: {
|
|||
return props.operation ?? activeOperation ?? null;
|
||||
}, [props.operation, activeOperation]);
|
||||
|
||||
const path = useMemo(() => {
|
||||
return props.path.join('.');
|
||||
}, [props.path]);
|
||||
|
||||
const isOpen = useMemo(() => {
|
||||
return props.openPaths.includes(props.path.join('.'));
|
||||
}, [props.openPaths, props.path]);
|
||||
return props.openPaths.includes(path) || !!props.forcedOpenPaths?.has(path);
|
||||
}, [props.openPaths, props.forcedOpenPaths, path]);
|
||||
|
||||
const setIsOpen = useCallback(
|
||||
(isOpen: boolean) => {
|
||||
props.setOpenPaths(
|
||||
isOpen
|
||||
? [...props.openPaths, props.path.join('.')]
|
||||
: props.openPaths.filter(path => path !== props.path.join('.')),
|
||||
isOpen ? [...props.openPaths, path] : props.openPaths.filter(openPath => openPath !== path),
|
||||
);
|
||||
},
|
||||
[props],
|
||||
[path, props],
|
||||
);
|
||||
|
||||
const fields = useMemo(
|
||||
|
|
@ -306,14 +403,65 @@ export const BuilderObjectField = (props: {
|
|||
return args.some(arg => isArgInQuery(operation?.query ?? '', props.path.join('.'), arg.name));
|
||||
}, [operation?.query, args, props.path]);
|
||||
|
||||
const path = useMemo(() => {
|
||||
return props.path.join('.');
|
||||
}, [props.path]);
|
||||
|
||||
const isInQuery = useMemo(() => {
|
||||
return isPathInQuery(operation?.query ?? '', path);
|
||||
}, [operation?.query, path]);
|
||||
|
||||
const shouldHighlight = useMemo(() => {
|
||||
const splittedName = splitIdentifier(props.field.name);
|
||||
|
||||
return splittedName.some(p => props.searchValue?.toLowerCase().includes(p.toLowerCase()));
|
||||
}, [props.searchValue, props.field.name]);
|
||||
|
||||
if (props.isSearchActive && props.visiblePaths && !props.visiblePaths.has(path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (props.disableChildren) {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'text-muted-foreground bg-card p-1! group sticky top-0 z-10 w-full justify-start overflow-hidden text-xs',
|
||||
{
|
||||
'text-foreground-primary': isInQuery,
|
||||
},
|
||||
)}
|
||||
style={{
|
||||
top: `${(props.path.length - 2) * 32}px`,
|
||||
}}
|
||||
size="sm"
|
||||
>
|
||||
<div className="bg-card absolute left-0 top-0 -z-20 size-full" />
|
||||
<div className="group-hover:bg-accent/50 absolute left-0 top-0 -z-10 size-full transition-colors" />
|
||||
<Checkbox
|
||||
onClick={e => e.stopPropagation()}
|
||||
checked={isInQuery}
|
||||
disabled={activeTab?.type !== 'operation' || props.isReadOnly}
|
||||
onCheckedChange={checked => {
|
||||
if (checked) {
|
||||
setIsOpen(true);
|
||||
addPathToActiveOperation(path);
|
||||
} else {
|
||||
deletePathFromActiveOperation(path);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<BoxIcon className="size-4 text-rose-400" />
|
||||
{props.label ?? (
|
||||
<span
|
||||
className={cn({
|
||||
'text-primary-foreground bg-primary -mx-0.5 rounded-sm px-0.5': shouldHighlight,
|
||||
})}
|
||||
>
|
||||
{props.field.name}
|
||||
</span>
|
||||
)}
|
||||
: <GraphQLType type={props.field.type} />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Collapsible key={props.field.name} open={isOpen} onOpenChange={setIsOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
|
|
@ -351,7 +499,16 @@ export const BuilderObjectField = (props: {
|
|||
}}
|
||||
/>
|
||||
<BoxIcon className="size-4 text-rose-400" />
|
||||
{props.field.name}: <GraphQLType type={props.field.type} />
|
||||
{props.label ?? (
|
||||
<span
|
||||
className={cn({
|
||||
'text-primary-foreground bg-primary -mx-0.5 rounded-sm px-0.5': shouldHighlight,
|
||||
})}
|
||||
>
|
||||
{props.field.name}
|
||||
</span>
|
||||
)}
|
||||
: <GraphQLType type={props.field.type} />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="border-border relative z-0 ml-4 flex flex-col border-l pl-1">
|
||||
|
|
@ -402,8 +559,12 @@ export const BuilderObjectField = (props: {
|
|||
path={[...props.path, child.name]}
|
||||
openPaths={props.openPaths}
|
||||
setOpenPaths={props.setOpenPaths}
|
||||
visiblePaths={props.visiblePaths}
|
||||
forcedOpenPaths={props.forcedOpenPaths}
|
||||
isSearchActive={props.isSearchActive}
|
||||
isReadOnly={props.isReadOnly}
|
||||
operation={operation}
|
||||
searchValue={props.searchValue}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -418,8 +579,14 @@ export const BuilderField = (props: {
|
|||
path: string[];
|
||||
openPaths: string[];
|
||||
setOpenPaths: (openPaths: string[]) => void;
|
||||
visiblePaths?: Set<string> | null;
|
||||
forcedOpenPaths?: Set<string> | null;
|
||||
isSearchActive?: boolean;
|
||||
operation?: LaboratoryOperation | null;
|
||||
isReadOnly?: boolean;
|
||||
searchValue?: string;
|
||||
label?: React.ReactNode;
|
||||
disableChildren?: boolean;
|
||||
}) => {
|
||||
const { schema } = useLaboratory();
|
||||
|
||||
|
|
@ -437,8 +604,14 @@ export const BuilderField = (props: {
|
|||
path={props.path}
|
||||
openPaths={props.openPaths}
|
||||
setOpenPaths={props.setOpenPaths}
|
||||
visiblePaths={props.visiblePaths}
|
||||
forcedOpenPaths={props.forcedOpenPaths}
|
||||
isSearchActive={props.isSearchActive}
|
||||
isReadOnly={props.isReadOnly}
|
||||
operation={props.operation}
|
||||
searchValue={props.searchValue}
|
||||
label={props.label}
|
||||
disableChildren={props.disableChildren}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -449,12 +622,116 @@ export const BuilderField = (props: {
|
|||
path={props.path}
|
||||
openPaths={props.openPaths}
|
||||
setOpenPaths={props.setOpenPaths}
|
||||
visiblePaths={props.visiblePaths}
|
||||
forcedOpenPaths={props.forcedOpenPaths}
|
||||
isSearchActive={props.isSearchActive}
|
||||
isReadOnly={props.isReadOnly}
|
||||
operation={props.operation}
|
||||
searchValue={props.searchValue}
|
||||
label={props.label}
|
||||
disableChildren={props.disableChildren}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
enum BuilderSearchResultMode {
|
||||
LIST = 'list',
|
||||
TREE = 'tree',
|
||||
}
|
||||
|
||||
export const BuilderSearchResults = (props: {
|
||||
type: 'query' | 'mutation' | 'subscription';
|
||||
fields: GraphQLField<unknown, unknown, unknown>[];
|
||||
openPaths: string[];
|
||||
setOpenPaths: (openPaths: string[]) => void;
|
||||
visiblePaths: Set<string> | null;
|
||||
matchedPaths: string[];
|
||||
forcedOpenPaths: Set<string> | null;
|
||||
isSearchActive: boolean;
|
||||
mode: BuilderSearchResultMode;
|
||||
isReadOnly: boolean;
|
||||
operation: LaboratoryOperation | null;
|
||||
searchValue: string;
|
||||
schema: GraphQLSchema;
|
||||
tab: OperationTypeNode;
|
||||
}) => {
|
||||
if (props.mode === BuilderSearchResultMode.LIST) {
|
||||
return props.matchedPaths.map(path => {
|
||||
const field = getFieldByPath(path, props.schema);
|
||||
|
||||
if (!field) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<BuilderField
|
||||
key={path}
|
||||
field={field}
|
||||
path={[path]}
|
||||
openPaths={props.openPaths}
|
||||
setOpenPaths={props.setOpenPaths}
|
||||
visiblePaths={props.visiblePaths}
|
||||
forcedOpenPaths={props.forcedOpenPaths}
|
||||
isSearchActive={props.isSearchActive}
|
||||
isReadOnly={props.isReadOnly}
|
||||
operation={props.operation}
|
||||
searchValue={props.searchValue}
|
||||
disableChildren
|
||||
label={
|
||||
<span>
|
||||
{path.split('.').map((part, index) => {
|
||||
const splittedPart = splitIdentifier(part);
|
||||
|
||||
const isMatch = splittedPart.some(p =>
|
||||
props.searchValue.toLowerCase().includes(p.toLowerCase()),
|
||||
);
|
||||
|
||||
if (isMatch) {
|
||||
return (
|
||||
<Fragment key={index}>
|
||||
<span className="text-primary-foreground bg-primary -mx-0.5 rounded-sm px-0.5">
|
||||
{part}
|
||||
</span>
|
||||
{index < path.split('.').length - 1 && '.'}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span key={index}>
|
||||
{part}
|
||||
{index < path.split('.').length - 1 && '.'}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return props.fields
|
||||
.filter(field => props.visiblePaths?.has(`${props.tab}.${field.name}`))
|
||||
.map(field => {
|
||||
return (
|
||||
<BuilderField
|
||||
key={field.name}
|
||||
field={field}
|
||||
path={[props.tab, field.name]}
|
||||
openPaths={props.openPaths}
|
||||
setOpenPaths={props.setOpenPaths}
|
||||
visiblePaths={props.visiblePaths}
|
||||
forcedOpenPaths={props.forcedOpenPaths}
|
||||
isSearchActive={props.isSearchActive}
|
||||
isReadOnly={props.isReadOnly}
|
||||
operation={props.operation}
|
||||
searchValue={props.searchValue}
|
||||
/>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const Builder = (props: {
|
||||
operation?: LaboratoryOperation | null;
|
||||
isReadOnly?: boolean;
|
||||
|
|
@ -462,7 +739,12 @@ export const Builder = (props: {
|
|||
const { schema, activeOperation, endpoint, setEndpoint, defaultEndpoint } = useLaboratory();
|
||||
|
||||
const [endpointValue, setEndpointValue] = useState<string>(endpoint ?? '');
|
||||
const [searchValue, setSearchValue] = useState<string>('');
|
||||
const deferredSearchValue = useDeferredValue(
|
||||
searchValue[searchValue.length - 1] === '.' ? searchValue.slice(0, -1) : searchValue,
|
||||
);
|
||||
const [openPaths, setOpenPaths] = useState<string[]>([]);
|
||||
const [tabValue, setTabValue] = useState<OperationTypeNode>(OperationTypeNode.QUERY);
|
||||
|
||||
const operation = useMemo(() => {
|
||||
return props.operation ?? activeOperation ?? null;
|
||||
|
|
@ -474,7 +756,7 @@ export const Builder = (props: {
|
|||
|
||||
if (newOpenPaths.length > 0) {
|
||||
setOpenPaths(newOpenPaths);
|
||||
setTabValue(newOpenPaths[0]);
|
||||
setTabValue(newOpenPaths[0] as OperationTypeNode);
|
||||
}
|
||||
}
|
||||
}, [schema, operation?.query]);
|
||||
|
|
@ -494,7 +776,32 @@ export const Builder = (props: {
|
|||
[schema],
|
||||
);
|
||||
|
||||
const [tabValue, setTabValue] = useState<string>('query');
|
||||
const isSearchActive = deferredSearchValue.trim().length > 0;
|
||||
|
||||
const searchResult = useMemo(() => {
|
||||
if (!schema || !isSearchActive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return searchSchemaPaths(schema, deferredSearchValue, {
|
||||
maxDepth: 8,
|
||||
maxMatches: 100,
|
||||
maxNodes: 10000,
|
||||
operationTypes: [tabValue],
|
||||
});
|
||||
}, [schema, deferredSearchValue, isSearchActive, tabValue]);
|
||||
|
||||
console.log(searchResult);
|
||||
|
||||
const visiblePaths = isSearchActive ? (searchResult?.visiblePaths ?? null) : null;
|
||||
const forcedOpenPaths =
|
||||
isSearchActive && deferredSearchValue.includes('.')
|
||||
? (searchResult?.forcedOpenPaths ?? null)
|
||||
: null;
|
||||
|
||||
const [searchResultMode, setSearchResultMode] = useState<BuilderSearchResultMode>(
|
||||
BuilderSearchResultMode.TREE,
|
||||
);
|
||||
|
||||
const throttleSetEndpoint = useMemo(
|
||||
() =>
|
||||
|
|
@ -563,7 +870,7 @@ export const Builder = (props: {
|
|||
<Tabs
|
||||
key={operation?.id}
|
||||
value={tabValue}
|
||||
onValueChange={setTabValue}
|
||||
onValueChange={value => setTabValue(value as OperationTypeNode)}
|
||||
className="flex size-full flex-col gap-0"
|
||||
>
|
||||
<div className="border-border flex items-center border-b p-3">
|
||||
|
|
@ -587,47 +894,163 @@ export const Builder = (props: {
|
|||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
{schema && (
|
||||
<div className="border-border sticky top-0 z-10 border-b p-3">
|
||||
<InputGroup className="pr-0">
|
||||
<InputGroupInput
|
||||
placeholder="Search fields"
|
||||
value={searchValue}
|
||||
onChange={e => setSearchValue(e.currentTarget.value)}
|
||||
/>
|
||||
<InputGroupAddon>
|
||||
<SearchIcon className="text-muted-foreground size-4" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupAddon align="inline-end" className="py-0 pr-1.5">
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
variant="outline"
|
||||
defaultValue={searchResultMode}
|
||||
onValueChange={value => setSearchResultMode(value as BuilderSearchResultMode)}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<ToggleGroupItem
|
||||
value={BuilderSearchResultMode.TREE}
|
||||
aria-label="Toggle tree"
|
||||
className="h-6 !rounded-l-sm !rounded-r-none border-r-0 p-2"
|
||||
>
|
||||
<ListTreeIcon className="size-4" />
|
||||
</ToggleGroupItem>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Tree</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<ToggleGroupItem
|
||||
value={BuilderSearchResultMode.LIST}
|
||||
aria-label="Toggle list"
|
||||
className="h-6 !rounded-l-none !rounded-r-sm p-2"
|
||||
>
|
||||
<TextAlignStartIcon className="size-4" />
|
||||
</ToggleGroupItem>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>List</TooltipContent>
|
||||
</Tooltip>
|
||||
</ToggleGroup>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ScrollArea className="h-full font-mono">
|
||||
<div className="p-3">
|
||||
<TabsContent value="query">
|
||||
{queryFields?.map(field => (
|
||||
<BuilderField
|
||||
key={field.name}
|
||||
field={field}
|
||||
path={['query', field.name]}
|
||||
{isSearchActive ? (
|
||||
<BuilderSearchResults
|
||||
type="query"
|
||||
fields={queryFields}
|
||||
openPaths={openPaths}
|
||||
setOpenPaths={setOpenPaths}
|
||||
isReadOnly={props.isReadOnly}
|
||||
visiblePaths={visiblePaths}
|
||||
matchedPaths={searchResult?.matchedPaths ?? []}
|
||||
forcedOpenPaths={searchResult?.forcedOpenPaths ?? null}
|
||||
isSearchActive={isSearchActive}
|
||||
mode={searchResultMode}
|
||||
isReadOnly={props.isReadOnly ?? false}
|
||||
operation={operation}
|
||||
searchValue={deferredSearchValue}
|
||||
schema={schema}
|
||||
tab={tabValue}
|
||||
/>
|
||||
))}
|
||||
) : (
|
||||
queryFields.map(field => (
|
||||
<BuilderField
|
||||
key={field.name}
|
||||
field={field}
|
||||
path={['query', field.name]}
|
||||
openPaths={openPaths}
|
||||
setOpenPaths={setOpenPaths}
|
||||
visiblePaths={visiblePaths}
|
||||
forcedOpenPaths={forcedOpenPaths}
|
||||
isSearchActive={isSearchActive}
|
||||
isReadOnly={props.isReadOnly}
|
||||
operation={operation}
|
||||
searchValue={deferredSearchValue}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="mutation">
|
||||
{mutationFields?.map(field => (
|
||||
<BuilderField
|
||||
key={field.name}
|
||||
field={field}
|
||||
path={['mutation', field.name]}
|
||||
{isSearchActive ? (
|
||||
<BuilderSearchResults
|
||||
type="mutation"
|
||||
fields={mutationFields}
|
||||
openPaths={openPaths}
|
||||
setOpenPaths={setOpenPaths}
|
||||
isReadOnly={props.isReadOnly}
|
||||
visiblePaths={visiblePaths}
|
||||
matchedPaths={searchResult?.matchedPaths ?? []}
|
||||
forcedOpenPaths={searchResult?.forcedOpenPaths ?? null}
|
||||
isSearchActive={isSearchActive}
|
||||
mode={searchResultMode}
|
||||
isReadOnly={props.isReadOnly ?? false}
|
||||
operation={operation}
|
||||
searchValue={deferredSearchValue}
|
||||
schema={schema}
|
||||
tab={tabValue}
|
||||
/>
|
||||
))}
|
||||
) : (
|
||||
mutationFields.map(field => (
|
||||
<BuilderField
|
||||
key={field.name}
|
||||
field={field}
|
||||
path={['mutation', field.name]}
|
||||
openPaths={openPaths}
|
||||
setOpenPaths={setOpenPaths}
|
||||
visiblePaths={visiblePaths}
|
||||
forcedOpenPaths={forcedOpenPaths}
|
||||
isSearchActive={isSearchActive}
|
||||
isReadOnly={props.isReadOnly}
|
||||
operation={operation}
|
||||
searchValue={deferredSearchValue}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="subscription">
|
||||
{subscriptionFields?.map(field => (
|
||||
<BuilderField
|
||||
key={field.name}
|
||||
field={field}
|
||||
path={['subscription', field.name]}
|
||||
{isSearchActive ? (
|
||||
<BuilderSearchResults
|
||||
type="subscription"
|
||||
fields={subscriptionFields}
|
||||
openPaths={openPaths}
|
||||
setOpenPaths={setOpenPaths}
|
||||
isReadOnly={props.isReadOnly}
|
||||
visiblePaths={visiblePaths}
|
||||
matchedPaths={searchResult?.matchedPaths ?? []}
|
||||
forcedOpenPaths={searchResult?.forcedOpenPaths ?? null}
|
||||
isSearchActive={isSearchActive}
|
||||
mode={searchResultMode}
|
||||
isReadOnly={props.isReadOnly ?? false}
|
||||
operation={operation}
|
||||
searchValue={deferredSearchValue}
|
||||
schema={schema}
|
||||
tab={tabValue}
|
||||
/>
|
||||
))}
|
||||
) : (
|
||||
subscriptionFields.map(field => (
|
||||
<BuilderField
|
||||
key={field.name}
|
||||
field={field}
|
||||
path={['subscription', field.name]}
|
||||
openPaths={openPaths}
|
||||
setOpenPaths={setOpenPaths}
|
||||
visiblePaths={visiblePaths}
|
||||
forcedOpenPaths={forcedOpenPaths}
|
||||
isSearchActive={isSearchActive}
|
||||
isReadOnly={props.isReadOnly}
|
||||
operation={operation}
|
||||
searchValue={deferredSearchValue}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</TabsContent>
|
||||
</div>
|
||||
<ScrollBar className="relative z-50" />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,81 @@
|
|||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { type VariantProps } from 'class-variance-authority';
|
||||
import { ToggleGroup as ToggleGroupPrimitive } from 'radix-ui';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import { toggleVariants } from './toggle';
|
||||
|
||||
const ToggleGroupContext = React.createContext<
|
||||
VariantProps<typeof toggleVariants> & {
|
||||
spacing?: number;
|
||||
}
|
||||
>({
|
||||
size: 'default',
|
||||
variant: 'default',
|
||||
spacing: 0,
|
||||
});
|
||||
|
||||
function ToggleGroup({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
spacing = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants> & {
|
||||
spacing?: number;
|
||||
}) {
|
||||
return (
|
||||
<ToggleGroupPrimitive.Root
|
||||
data-slot="toggle-group"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
data-spacing={spacing}
|
||||
style={{ '--gap': spacing } as React.CSSProperties}
|
||||
className={cn(
|
||||
'group/toggle-group data-[spacing=default]:data-[variant=outline]:shadow-xs flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ToggleGroupContext.Provider value={{ variant, size, spacing }}>
|
||||
{children}
|
||||
</ToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleGroupItem({
|
||||
className,
|
||||
children,
|
||||
variant,
|
||||
size,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> & VariantProps<typeof toggleVariants>) {
|
||||
const context = React.useContext(ToggleGroupContext);
|
||||
|
||||
return (
|
||||
<ToggleGroupPrimitive.Item
|
||||
data-slot="toggle-group-item"
|
||||
data-variant={context.variant || variant}
|
||||
data-size={context.size || size}
|
||||
data-spacing={context.spacing}
|
||||
className={cn(
|
||||
toggleVariants({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
'w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10',
|
||||
'data-[spacing=0]:rounded-none data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:data-[variant=outline]:first:border-l data-[spacing=0]:last:rounded-r-md',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ToggleGroupPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
export { ToggleGroup, ToggleGroupItem };
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { Toggle as TogglePrimitive } from 'radix-ui';
|
||||
import { cn } from '../../../lib/utils';
|
||||
|
||||
const toggleVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,box-shadow] outline-none hover:bg-muted hover:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-transparent',
|
||||
outline:
|
||||
'border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 min-w-9 px-2',
|
||||
sm: 'h-8 min-w-8 px-1.5',
|
||||
lg: 'h-10 min-w-10 px-2.5',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Toggle({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TogglePrimitive.Root> & VariantProps<typeof toggleVariants>) {
|
||||
return (
|
||||
<TogglePrimitive.Root
|
||||
data-slot="toggle"
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Toggle, toggleVariants };
|
||||
|
|
@ -1,9 +1,12 @@
|
|||
import {
|
||||
GraphQLEnumType,
|
||||
GraphQLInterfaceType,
|
||||
GraphQLList,
|
||||
GraphQLNonNull,
|
||||
GraphQLObjectType,
|
||||
GraphQLScalarType,
|
||||
GraphQLSchema,
|
||||
GraphQLUnionType,
|
||||
Kind,
|
||||
OperationTypeNode,
|
||||
parse,
|
||||
|
|
@ -13,6 +16,7 @@ import {
|
|||
type DocumentNode,
|
||||
type FieldNode,
|
||||
type GraphQLField,
|
||||
type GraphQLNamedType,
|
||||
type GraphQLOutputType,
|
||||
type GraphQLType,
|
||||
type OperationDefinitionNode,
|
||||
|
|
@ -910,8 +914,423 @@ export function getOpenPaths(query: string): string[] {
|
|||
return extractPaths(query).map(v => v.join('.'));
|
||||
}
|
||||
|
||||
type SearchableFieldType = GraphQLObjectType | GraphQLInterfaceType;
|
||||
|
||||
export type SchemaPathSearchEntry = {
|
||||
path: string;
|
||||
segmentsLower: string[];
|
||||
pathLower: string;
|
||||
pathWithoutOperationLower: string;
|
||||
};
|
||||
|
||||
export type SchemaSearchResult = {
|
||||
matchedPaths: string[];
|
||||
visiblePaths: Set<string>;
|
||||
forcedOpenPaths: Set<string>;
|
||||
hasMore: boolean;
|
||||
nodesVisited: number;
|
||||
};
|
||||
|
||||
function unwrapNamedType(type: GraphQLOutputType): GraphQLNamedType {
|
||||
if (type instanceof GraphQLNonNull || type instanceof GraphQLList) {
|
||||
return unwrapNamedType(type.ofType);
|
||||
}
|
||||
|
||||
return type;
|
||||
}
|
||||
|
||||
function isSearchableFieldType(type: GraphQLNamedType): type is SearchableFieldType {
|
||||
return type instanceof GraphQLObjectType || type instanceof GraphQLInterfaceType;
|
||||
}
|
||||
|
||||
function isLeafFieldType(type: GraphQLNamedType): boolean {
|
||||
return (
|
||||
type instanceof GraphQLScalarType ||
|
||||
type instanceof GraphQLEnumType ||
|
||||
type instanceof GraphQLUnionType
|
||||
);
|
||||
}
|
||||
|
||||
function collectOperationPaths(
|
||||
operation: OperationTypeNode,
|
||||
rootType: SearchableFieldType,
|
||||
result: string[][],
|
||||
maxDepth: number,
|
||||
) {
|
||||
const rootFields = Object.values(rootType.getFields());
|
||||
const pathBuffer: string[] = [operation];
|
||||
|
||||
const walk = (field: GraphQLField<unknown, unknown, unknown>, seenTypes: Set<string>) => {
|
||||
pathBuffer.push(field.name);
|
||||
result.push([...pathBuffer]);
|
||||
|
||||
if (pathBuffer.length >= maxDepth + 1) {
|
||||
pathBuffer.pop();
|
||||
return;
|
||||
}
|
||||
|
||||
const namedType = unwrapNamedType(field.type);
|
||||
|
||||
if (isLeafFieldType(namedType) || !isSearchableFieldType(namedType)) {
|
||||
pathBuffer.pop();
|
||||
return;
|
||||
}
|
||||
|
||||
if (seenTypes.has(namedType.name)) {
|
||||
pathBuffer.pop();
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSeenTypes = new Set(seenTypes);
|
||||
nextSeenTypes.add(namedType.name);
|
||||
|
||||
for (const childField of Object.values(namedType.getFields())) {
|
||||
walk(childField, nextSeenTypes);
|
||||
}
|
||||
|
||||
pathBuffer.pop();
|
||||
};
|
||||
|
||||
for (const rootField of rootFields) {
|
||||
walk(rootField, new Set([rootType.name]));
|
||||
}
|
||||
}
|
||||
|
||||
export function schemaToPaths(schema: GraphQLSchema, maxDepth = 8): string[][] {
|
||||
const result: string[][] = [];
|
||||
|
||||
const operationTypes: [OperationTypeNode, SearchableFieldType | null][] = [
|
||||
[OperationTypeNode.QUERY, schema.getQueryType() ?? null],
|
||||
[OperationTypeNode.MUTATION, schema.getMutationType() ?? null],
|
||||
[OperationTypeNode.SUBSCRIPTION, schema.getSubscriptionType() ?? null],
|
||||
];
|
||||
|
||||
for (const [operation, rootType] of operationTypes) {
|
||||
if (!rootType) {
|
||||
continue;
|
||||
}
|
||||
|
||||
collectOperationPaths(operation, rootType, result, maxDepth);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function pathsToStrings(paths: readonly string[][]): string[] {
|
||||
return paths.map(path => path.join('.'));
|
||||
}
|
||||
|
||||
export function createSchemaPathSearchIndex(paths: readonly string[]): SchemaPathSearchEntry[] {
|
||||
return paths.map(path => {
|
||||
const segments = path.split('.');
|
||||
const segmentsLower = segments.map(segment => segment.toLowerCase());
|
||||
|
||||
return {
|
||||
path,
|
||||
segmentsLower,
|
||||
pathLower: path.toLowerCase(),
|
||||
pathWithoutOperationLower: segmentsLower.slice(1).join('.'),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function matchesDottedSearch(entry: SchemaPathSearchEntry, searchSegments: string[]): boolean {
|
||||
const segmentsLower = entry.segmentsLower;
|
||||
const maxStart = segmentsLower.length - searchSegments.length;
|
||||
|
||||
if (maxStart < 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let start = 1; start <= maxStart; ++start) {
|
||||
let matches = true;
|
||||
|
||||
for (let i = 0; i < searchSegments.length; ++i) {
|
||||
if (!segmentsLower[start + i].includes(searchSegments[i])) {
|
||||
matches = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (matches) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function searchSchemaPathIndex(
|
||||
index: readonly SchemaPathSearchEntry[],
|
||||
search: string,
|
||||
limit = 1000,
|
||||
): string[] {
|
||||
const normalizedSearch = search.trim().toLowerCase();
|
||||
|
||||
if (!normalizedSearch) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const searchSegments = normalizedSearch.split('.').filter(Boolean);
|
||||
const useSegmentSearch = searchSegments.length > 1;
|
||||
const result: string[] = [];
|
||||
|
||||
for (const entry of index) {
|
||||
const isMatch = useSegmentSearch
|
||||
? matchesDottedSearch(entry, searchSegments)
|
||||
: entry.pathWithoutOperationLower.includes(normalizedSearch) ||
|
||||
entry.pathLower.includes(normalizedSearch);
|
||||
|
||||
if (!isMatch) {
|
||||
continue;
|
||||
}
|
||||
|
||||
result.push(entry.path);
|
||||
|
||||
if (result.length >= limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function buildVisiblePathSet(paths: readonly string[]): Set<string> {
|
||||
const result = new Set<string>();
|
||||
|
||||
for (const path of paths) {
|
||||
let dotIndex = path.indexOf('.');
|
||||
|
||||
while (dotIndex !== -1) {
|
||||
result.add(path.slice(0, dotIndex));
|
||||
dotIndex = path.indexOf('.', dotIndex + 1);
|
||||
}
|
||||
|
||||
result.add(path);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function buildForcedOpenPathSet(paths: readonly string[]): Set<string> {
|
||||
const result = new Set<string>();
|
||||
|
||||
for (const path of paths) {
|
||||
let dotIndex = path.indexOf('.');
|
||||
|
||||
while (dotIndex !== -1) {
|
||||
result.add(path.slice(0, dotIndex));
|
||||
dotIndex = path.indexOf('.', dotIndex + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function matchSearchAgainstPath(
|
||||
pathSegmentsLower: readonly string[],
|
||||
normalizedSearch: string,
|
||||
searchSegments: readonly string[],
|
||||
): boolean {
|
||||
if (searchSegments.length > 1) {
|
||||
const maxStart = pathSegmentsLower.length - searchSegments.length;
|
||||
|
||||
if (maxStart < 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let start = 0; start <= maxStart; ++start) {
|
||||
let matched = true;
|
||||
|
||||
for (let i = 0; i < searchSegments.length; ++i) {
|
||||
if (!pathSegmentsLower[start + i].includes(searchSegments[i])) {
|
||||
matched = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (matched) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const segment of pathSegmentsLower) {
|
||||
if (segment.includes(normalizedSearch)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function searchSchemaPaths(
|
||||
schema: GraphQLSchema,
|
||||
search: string,
|
||||
options?: {
|
||||
maxDepth?: number;
|
||||
maxMatches?: number;
|
||||
maxNodes?: number;
|
||||
operationTypes?: OperationTypeNode[];
|
||||
},
|
||||
): SchemaSearchResult {
|
||||
const normalizedSearch = search.trim().toLowerCase();
|
||||
|
||||
if (!normalizedSearch) {
|
||||
return {
|
||||
matchedPaths: [],
|
||||
visiblePaths: new Set(),
|
||||
forcedOpenPaths: new Set(),
|
||||
hasMore: false,
|
||||
nodesVisited: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const maxDepth = options?.maxDepth ?? 8;
|
||||
const maxMatches = options?.maxMatches ?? 500;
|
||||
const maxNodes = options?.maxNodes ?? 40000;
|
||||
const searchSegments = normalizedSearch.split('.').filter(Boolean);
|
||||
const matchedPaths: string[] = [];
|
||||
const visiblePaths = new Set<string>();
|
||||
const forcedOpenPaths = new Set<string>();
|
||||
|
||||
type Frame = {
|
||||
operation: OperationTypeNode;
|
||||
field: GraphQLField<unknown, unknown, unknown>;
|
||||
pathSegments: string[];
|
||||
typeTrail: string[];
|
||||
depth: number;
|
||||
};
|
||||
|
||||
const queue: Frame[] = [];
|
||||
let queueIndex = 0;
|
||||
|
||||
const operationTypes: [OperationTypeNode, SearchableFieldType | null][] = [
|
||||
[OperationTypeNode.QUERY, schema.getQueryType() ?? null],
|
||||
[OperationTypeNode.MUTATION, schema.getMutationType() ?? null],
|
||||
[OperationTypeNode.SUBSCRIPTION, schema.getSubscriptionType() ?? null],
|
||||
];
|
||||
|
||||
const filteredOperationTypes =
|
||||
options?.operationTypes && options.operationTypes.length > 0
|
||||
? operationTypes.filter(([operation]) => options.operationTypes!.includes(operation))
|
||||
: operationTypes;
|
||||
|
||||
for (const [operation, rootType] of filteredOperationTypes) {
|
||||
if (!rootType) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const rootField of Object.values(rootType.getFields())) {
|
||||
queue.push({
|
||||
operation,
|
||||
field: rootField,
|
||||
pathSegments: [rootField.name],
|
||||
typeTrail: [rootType.name],
|
||||
depth: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let nodesVisited = 0;
|
||||
let hasMore = false;
|
||||
|
||||
while (queueIndex < queue.length) {
|
||||
if (nodesVisited >= maxNodes || matchedPaths.length >= maxMatches) {
|
||||
hasMore = true;
|
||||
break;
|
||||
}
|
||||
|
||||
const frame = queue[queueIndex++] as Frame;
|
||||
++nodesVisited;
|
||||
|
||||
const path = `${frame.operation}.${frame.pathSegments.join('.')}`;
|
||||
const pathSegmentsLower = frame.pathSegments.map(segment => segment.toLowerCase());
|
||||
|
||||
if (matchSearchAgainstPath(pathSegmentsLower, normalizedSearch, searchSegments)) {
|
||||
matchedPaths.push(path);
|
||||
visiblePaths.add(path);
|
||||
|
||||
let dotIndex = path.indexOf('.');
|
||||
|
||||
while (dotIndex !== -1) {
|
||||
const parentPath = path.slice(0, dotIndex);
|
||||
visiblePaths.add(parentPath);
|
||||
forcedOpenPaths.add(parentPath);
|
||||
dotIndex = path.indexOf('.', dotIndex + 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (frame.depth >= maxDepth) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const namedType = unwrapNamedType(frame.field.type);
|
||||
|
||||
if (!isSearchableFieldType(namedType) || frame.typeTrail.includes(namedType.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextTrail = [...frame.typeTrail, namedType.name];
|
||||
|
||||
for (const childField of Object.values(namedType.getFields())) {
|
||||
queue.push({
|
||||
operation: frame.operation,
|
||||
field: childField,
|
||||
pathSegments: [...frame.pathSegments, childField.name],
|
||||
typeTrail: nextTrail,
|
||||
depth: frame.depth + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
matchedPaths,
|
||||
visiblePaths,
|
||||
forcedOpenPaths,
|
||||
hasMore,
|
||||
nodesVisited,
|
||||
};
|
||||
}
|
||||
|
||||
export function handleTemplate(query: string, env: Record<string, any>) {
|
||||
return query.replace(/\{\{(.*?)\}\}/g, (match, p1) => {
|
||||
return get(env, p1) ?? match;
|
||||
});
|
||||
}
|
||||
|
||||
export function getFieldByPath(path: string, schema: GraphQLSchema) {
|
||||
const [operation, ...segments] = path.split('.') as [OperationTypeNode, ...string[]];
|
||||
|
||||
let type: Maybe<GraphQLType>;
|
||||
|
||||
if (operation === 'query') {
|
||||
type = schema.getQueryType();
|
||||
} else if (operation === 'mutation') {
|
||||
type = schema.getMutationType();
|
||||
} else if (operation === 'subscription') {
|
||||
type = schema.getSubscriptionType();
|
||||
}
|
||||
|
||||
if (!type) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let field: Maybe<GraphQLField<unknown, unknown, unknown>>;
|
||||
|
||||
for (const segment of segments) {
|
||||
if (type instanceof GraphQLObjectType) {
|
||||
field = type.getFields()[segment] as GraphQLField<unknown, unknown, unknown>;
|
||||
|
||||
if (!field) {
|
||||
return null;
|
||||
}
|
||||
|
||||
type = field.type;
|
||||
}
|
||||
}
|
||||
|
||||
return field;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ export const useTests = (props: {
|
|||
(testId: string, task: Pick<LaboratoryTestTask, 'type' | 'data'>) => {
|
||||
const newTask: LaboratoryTestTask = {
|
||||
...task,
|
||||
id: crypto.randomUUID(),
|
||||
id: uuidv4(),
|
||||
next: null,
|
||||
} as LaboratoryTestTask;
|
||||
|
||||
|
|
|
|||
|
|
@ -8,3 +8,12 @@ export function cn(...inputs: ClassValue[]) {
|
|||
export function capitalize(str: string) {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
}
|
||||
|
||||
export function splitIdentifier(input: string): string[] {
|
||||
return input
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.map(w => w.toLowerCase());
|
||||
}
|
||||
|
|
|
|||
2076
pnpm-lock.yaml
2076
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue