Enhancement/lab query builder search (#7835)

This commit is contained in:
Michael Skorokhodov 2026-03-16 17:24:50 +01:00 committed by GitHub
parent 5b7921d763
commit 7f58cb856b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 2829 additions and 331 deletions

View 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

View file

@ -38,6 +38,7 @@
"zod": "^4.1.12"
},
"dependencies": {
"radix-ui": "^1.4.3",
"uuid": "^13.0.0"
},
"devDependencies": {

View file

@ -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" />

View file

@ -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 };

View file

@ -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 };

View file

@ -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;
}

View file

@ -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;

View file

@ -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());
}

File diff suppressed because it is too large Load diff