feat: new hive lab (#7374)

Co-authored-by: Piotr Monwid-Olechnowicz <hasparus@gmail.com>
This commit is contained in:
Michael Skorokhodov 2025-12-10 01:09:00 +01:00 committed by GitHub
parent 645c3019db
commit b4df418ce2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
67 changed files with 11412 additions and 696 deletions

View file

@ -98,6 +98,7 @@
"prettier-plugin-tailwindcss": "0.6.9",
"pretty-quick": "4.0.0",
"rimraf": "4.4.1",
"tailwindcss": "3.4.17",
"ts-node": "10.9.2",
"tsup": "8.4.0",
"tsx": "4.19.2",
@ -124,7 +125,8 @@
"cookie@<0.7.0": "0.7.2",
"tar-fs": "2.1.4",
"ip": "npm:neoip@2.1.0",
"miniflare@3>undici": "5.29.0"
"miniflare@3>undici": "5.29.0",
"tailwindcss": "3.4.17"
},
"patchedDependencies": {
"mjml-core@4.14.0": "patches/mjml-core@4.14.0.patch",

View file

@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/laboratory/components",
"utils": "@/laboratory/lib/utils",
"ui": "@/laboratory/components/ui",
"lib": "@/laboratory/lib",
"hooks": "@/laboratory/hooks"
},
"registries": {}
}

View file

@ -13,6 +13,10 @@
},
"devDependencies": {
"@date-fns/utc": "2.1.1",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fastify/cors": "9.0.1",
"@fastify/static": "7.0.4",
"@fastify/vite": "6.0.7",
@ -24,13 +28,14 @@
"@graphql-typed-document-node/core": "3.2.0",
"@headlessui/react": "2.2.0",
"@hookform/resolvers": "3.10.0",
"@monaco-editor/react": "4.7.0",
"@monaco-editor/react": "4.8.0-rc.2",
"@n1ru4l/react-time-ago": "1.1.0",
"@radix-ui/react-accordion": "1.2.2",
"@radix-ui/react-alert-dialog": "1.1.4",
"@radix-ui/react-avatar": "1.1.2",
"@radix-ui/react-checkbox": "1.1.3",
"@radix-ui/react-collapsible": "1.1.2",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "1.1.4",
"@radix-ui/react-dropdown-menu": "2.1.4",
"@radix-ui/react-hover-card": "1.1.4",
@ -46,6 +51,7 @@
"@radix-ui/react-switch": "1.1.2",
"@radix-ui/react-tabs": "1.1.2",
"@radix-ui/react-toast": "1.2.4",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "1.1.1",
"@radix-ui/react-tooltip": "1.1.6",
"@repeaterjs/repeater": "3.0.6",
@ -61,8 +67,10 @@
"@storybook/react-vite": "8.4.7",
"@stripe/react-stripe-js": "3.1.1",
"@stripe/stripe-js": "5.5.0",
"@tanstack/react-form": "^1.27.0",
"@tanstack/react-query": "5.63.0",
"@tanstack/react-router": "1.34.9",
"@tanstack/react-router-devtools": "^1.139.13",
"@tanstack/react-table": "8.20.6",
"@tanstack/router-devtools": "1.34.9",
"@tanstack/zod-adapter": "1.120.5",
@ -100,16 +108,21 @@
"graphiql": "4.0.0-alpha.5",
"graphql": "16.9.0",
"graphql-sse": "2.5.3",
"graphql-ws": "5.16.1",
"immer": "10.1.3",
"js-cookie": "3.0.5",
"json-schema-typed": "8.0.1",
"json-schema-yup-transformer": "1.6.12",
"jsurl2": "2.2.0",
"lodash": "4.17.21",
"lodash.debounce": "4.0.8",
"lucide-react": "0.469.0",
"lz-string": "^1.5.0",
"mini-svg-data-uri": "1.4.4",
"monaco-editor": "0.50.0",
"monaco-editor": "^0.52.2",
"monaco-graphql": "^1.7.2",
"monaco-themes": "0.4.4",
"next-themes": "^0.4.6",
"query-string": "9.1.1",
"react": "18.3.1",
"react-day-picker": "8.10.1",
@ -129,6 +142,7 @@
"recharts": "2.15.1",
"regenerator-runtime": "0.14.1",
"snarkdown": "2.0.0",
"sonner": "^2.0.7",
"storybook": "8.4.7",
"supertokens-auth-react": "0.38.0",
"supertokens-web-js": "0.9.0",
@ -142,6 +156,7 @@
"use-debounce": "10.0.4",
"valtio": "1.13.2",
"vite": "7.1.11",
"vite-plugin-monaco-editor": "^1.1.0",
"vite-tsconfig-paths": "5.1.4",
"wonka": "6.3.4",
"yup": "1.6.1",

View file

@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-restricted-imports */
import * as React from 'react';
import React from 'react';
import { cn } from '@/lib/utils';
import * as SeparatorPrimitive from '@radix-ui/react-separator';

View file

@ -101,6 +101,12 @@
--chart-1: 220 70% 50%;
--chart-2: 340 75% 55%;
}
.hive-laboratory {
--primary: 40 89% 60%;
--background: 223 70% 4%;
--card: 220 21.43% 5.49%;
}
}
@layer base {

View file

@ -0,0 +1,38 @@
import {
GraphQLEnumType,
GraphQLList,
GraphQLNonNull,
GraphQLScalarType,
type GraphQLInputType,
type GraphQLOutputType,
} from 'graphql';
export const GraphQLType = (props: {
type: GraphQLOutputType | GraphQLInputType;
className?: string;
}) => {
if (props.type instanceof GraphQLNonNull) {
return (
<span>
<GraphQLType type={props.type.ofType} />
<span className="!text-muted-foreground">!</span>
</span>
);
}
if (props.type instanceof GraphQLList) {
return (
<span>
<span className="!text-muted-foreground">[</span>
<GraphQLType type={props.type.ofType} />
<span className="!text-muted-foreground">]</span>
</span>
);
}
if (props.type instanceof GraphQLScalarType || props.type instanceof GraphQLEnumType) {
return <span className="text-teal-500 dark:text-teal-400">{props.type.name}</span>;
}
return <span className="text-amber-500 dark:text-amber-400">{props.type.name}</span>;
};

View file

@ -0,0 +1,45 @@
import type { LucideProps } from 'lucide-react';
export const GraphQLIcon = (props: LucideProps) => {
return (
<svg
width="100"
height="100"
viewBox="0 0 100 100"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M49.9994 6.90218L87.3224 28.4506V71.5475L49.9994 93.0959L12.6764 71.5475V28.4506L49.9994 6.90218ZM16.8641 30.8684V62.5242L44.2789 15.0405L16.8641 30.8684ZM49.9994 13.5077L18.3969 68.2448H81.6019L49.9994 13.5077ZM77.4142 72.4325H22.5846L49.9994 88.2604L77.4142 72.4325ZM83.1347 62.5242L55.7199 15.0405L83.1347 30.8684V62.5242Z"
fill="currentColor"
/>
<path
d="M49.9994 18.14C54.8706 18.14 58.8194 14.1912 58.8194 9.32C58.8194 4.44885 54.8706 0.5 49.9994 0.5C45.1283 0.5 41.1794 4.44885 41.1794 9.32C41.1794 14.1912 45.1283 18.14 49.9994 18.14Z"
fill="currentColor"
/>
<path
d="M85.2286 38.4796C90.0998 38.4796 94.0486 34.5308 94.0486 29.6596C94.0486 24.7884 90.0998 20.8396 85.2286 20.8396C80.3575 20.8396 76.4086 24.7884 76.4086 29.6596C76.4086 34.5308 80.3575 38.4796 85.2286 38.4796Z"
fill="currentColor"
/>
<path
d="M85.2286 79.1587C90.0998 79.1587 94.0486 75.2099 94.0486 70.3387C94.0486 65.4675 90.0998 61.5187 85.2286 61.5187C80.3575 61.5187 76.4086 65.4675 76.4086 70.3387C76.4086 75.2099 80.3575 79.1587 85.2286 79.1587Z"
fill="currentColor"
/>
<path
d="M49.9994 99.4982C54.8706 99.4982 58.8194 95.5494 58.8194 90.6782C58.8194 85.807 54.8706 81.8582 49.9994 81.8582C45.1283 81.8582 41.1794 85.807 41.1794 90.6782C41.1794 95.5494 45.1283 99.4982 49.9994 99.4982Z"
fill="currentColor"
/>
<path
d="M14.7653 79.1587C19.6365 79.1587 23.5853 75.2099 23.5853 70.3387C23.5853 65.4675 19.6365 61.5187 14.7653 61.5187C9.89416 61.5187 5.94531 65.4675 5.94531 70.3387C5.94531 75.2099 9.89416 79.1587 14.7653 79.1587Z"
fill="currentColor"
/>
<path
d="M14.7653 38.4796C19.6365 38.4796 23.5853 34.5308 23.5853 29.6596C23.5853 24.7884 19.6365 20.8396 14.7653 20.8396C9.89416 20.8396 5.94531 24.7884 5.94531 29.6596C5.94531 34.5308 9.89416 38.4796 14.7653 38.4796Z"
fill="currentColor"
/>
</svg>
);
};

View file

@ -0,0 +1,611 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
GraphQLEnumType,
GraphQLObjectType,
GraphQLScalarType,
GraphQLUnionType,
type GraphQLArgument,
type GraphQLField,
} from 'graphql';
import { BoxIcon, ChevronDownIcon, CopyMinusIcon, CuboidIcon, FolderIcon } from 'lucide-react';
import { GraphQLType } from '@/laboratory/components/graphql-type';
import { useLaboratory } from '@/laboratory/components/laboratory/context';
import { Button } from '@/laboratory/components/ui/button';
import { Checkbox } from '@/laboratory/components/ui/checkbox';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/laboratory/components/ui/collapsible';
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/laboratory/components/ui/empty';
import { ScrollArea, ScrollBar } from '@/laboratory/components/ui/scroll-area';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/laboratory/components/ui/tabs';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/laboratory/components/ui/tooltip';
import type { LaboratoryOperation } from '@/laboratory/lib/operations';
import { getOpenPaths, isArgInQuery, isPathInQuery } from '@/laboratory/lib/operations.utils';
import { cn } from '@/laboratory/lib/utils';
export const BuilderArgument = (props: {
field: GraphQLArgument;
path: string[];
isReadOnly?: boolean;
operation?: LaboratoryOperation | null;
}) => {
const {
schema,
activeOperation,
addArgToActiveOperation,
deleteArgFromActiveOperation,
activeTab,
} = useLaboratory();
const operation = useMemo(() => {
return props.operation ?? activeOperation ?? null;
}, [props.operation, activeOperation]);
const path = useMemo(() => {
return props.path.join('.');
}, [props.path]);
const isInQuery = useMemo(() => {
return isArgInQuery(operation?.query ?? '', path, props.field.name);
}, [operation?.query, path, props.field.name]);
return (
<Button
key={props.field.name}
variant="ghost"
className={cn('text-muted-foreground w-full justify-start !p-1 text-xs', {
'text-foreground-primary': isInQuery,
})}
size="sm"
>
<div className="size-4" />
<Checkbox
onClick={e => e.stopPropagation()}
checked={isInQuery}
disabled={activeTab?.type !== 'operation' || props.isReadOnly}
onCheckedChange={checked => {
if (!schema) {
return;
}
if (checked) {
addArgToActiveOperation(props.path.join('.'), props.field.name, schema);
} else {
deleteArgFromActiveOperation(props.path.join('.'), props.field.name);
}
}}
/>
<BoxIcon className="size-4 text-rose-500 dark:text-rose-400" />
{props.field.name}: <GraphQLType type={props.field.type} />
</Button>
);
};
export const BuilderScalarField = (props: {
field: GraphQLField<unknown, unknown, unknown>;
path: string[];
openPaths: string[];
setOpenPaths: (openPaths: string[]) => void;
isReadOnly?: boolean;
operation?: LaboratoryOperation | null;
}) => {
const { activeOperation, addPathToActiveOperation, deletePathFromActiveOperation, activeTab } =
useLaboratory();
const operation = useMemo(() => {
return props.operation ?? activeOperation ?? null;
}, [props.operation, activeOperation]);
const isOpen = useMemo(() => {
return props.openPaths.includes(props.path.join('.'));
}, [props.openPaths, props.path]);
const setIsOpen = useCallback(
(isOpen: boolean) => {
props.setOpenPaths(
isOpen
? [...props.openPaths, props.path.join('.')]
: props.openPaths.filter(path => path !== props.path.join('.')),
);
},
[props],
);
const path = useMemo(() => {
return props.path.join('.');
}, [props.path]);
const isInQuery = useMemo(() => {
return isPathInQuery(operation?.query ?? '', path);
}, [operation?.query, path]);
const args = useMemo(() => {
return (props.field as GraphQLField<unknown, unknown, unknown>).args ?? [];
}, [props.field]);
const hasArgs = useMemo(() => {
return args.some(arg => isArgInQuery(operation?.query ?? '', path, arg.name));
}, [operation?.query, args, path]);
if (args.length > 0) {
return (
<Collapsible key={props.field.name} open={isOpen} onOpenChange={setIsOpen}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className={cn(
'text-muted-foreground bg-card group sticky top-0 z-10 w-full justify-start overflow-hidden !p-1 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 dark:group-hover:bg-accent/50 absolute left-0 top-0 -z-10 size-full transition-colors" />
<ChevronDownIcon
className={cn('text-muted-foreground size-4 transition-all', {
'-rotate-90': !isOpen,
})}
/>
<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.field.name}: <GraphQLType type={props.field.type} />
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="border-border relative z-0 ml-3 flex flex-col border-l pl-2">
{isOpen && (
<div>
{args.length > 0 && (
<Collapsible>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className={cn(
'text-muted-foreground bg-card group sticky top-0 z-10 w-full justify-start overflow-hidden !p-1 text-xs',
{
'text-foreground-primary': hasArgs,
},
)}
style={{
top: `${(props.path.length - 1) * 32}px`,
}}
size="sm"
>
<ChevronDownIcon
className={cn('text-muted-foreground size-4 transition-all', {
'-rotate-90': !isOpen,
})}
/>
<Checkbox onClick={e => e.stopPropagation()} checked={hasArgs} disabled />
<CuboidIcon className="size-4 text-rose-400" />
[arguments]
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="border-border ml-3 flex flex-col border-l pl-2">
{args.map(arg => (
<BuilderArgument
key={arg.name}
field={arg}
path={[...props.path]}
isReadOnly={props.isReadOnly}
operation={operation}
/>
))}
</CollapsibleContent>
</Collapsible>
)}
</div>
)}
</CollapsibleContent>
</Collapsible>
);
}
return (
<Button
key={props.field.name}
variant="ghost"
className={cn('text-muted-foreground w-full justify-start !p-1 text-xs', {
'text-foreground-primary': isInQuery,
})}
size="sm"
>
<div className="size-4" />
<Checkbox
onClick={e => e.stopPropagation()}
checked={isInQuery}
disabled={activeTab?.type !== 'operation'}
onCheckedChange={checked => {
if (checked) {
addPathToActiveOperation(props.path.join('.'));
} else {
deletePathFromActiveOperation(props.path.join('.'));
}
}}
/>
<BoxIcon className="size-4 text-rose-400" />
{props.field.name}: <GraphQLType type={props.field.type} />
</Button>
);
};
export const BuilderObjectField = (props: {
field: GraphQLField<unknown, unknown, unknown>;
path: string[];
openPaths: string[];
setOpenPaths: (openPaths: string[]) => void;
isReadOnly?: boolean;
operation?: LaboratoryOperation | null;
}) => {
const {
schema,
activeOperation,
addPathToActiveOperation,
deletePathFromActiveOperation,
activeTab,
} = useLaboratory();
const operation = useMemo(() => {
return props.operation ?? activeOperation ?? null;
}, [props.operation, activeOperation]);
const isOpen = useMemo(() => {
return props.openPaths.includes(props.path.join('.'));
}, [props.openPaths, props.path]);
const setIsOpen = useCallback(
(isOpen: boolean) => {
props.setOpenPaths(
isOpen
? [...props.openPaths, props.path.join('.')]
: props.openPaths.filter(path => path !== props.path.join('.')),
);
},
[props],
);
const fields = useMemo(
() =>
Object.values(
(
schema?.getType(props.field.type.toString().replace(/\[|\]|!/g, '')) as GraphQLObjectType
)?.getFields?.() ?? {},
),
[schema, props.field.type],
);
const args = useMemo(() => {
return (props.field as GraphQLField<unknown, unknown, unknown>).args ?? [];
}, [props.field]);
const hasArgs = useMemo(() => {
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]);
return (
<Collapsible key={props.field.name} open={isOpen} onOpenChange={setIsOpen}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className={cn(
'text-muted-foreground bg-card group sticky top-0 z-10 w-full justify-start overflow-hidden !p-1 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 dark:group-hover:bg-accent/50 absolute left-0 top-0 -z-10 size-full transition-colors" />
<ChevronDownIcon
className={cn('text-muted-foreground size-4 transition-all', {
'-rotate-90': !isOpen,
})}
/>
<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.field.name}: <GraphQLType type={props.field.type} />
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="border-border relative z-0 ml-4 flex flex-col border-l pl-1">
{isOpen && (
<div>
{args.length > 0 && (
<Collapsible>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className={cn(
'text-muted-foreground bg-card group sticky top-0 z-10 w-full justify-start overflow-hidden !p-1 text-xs',
{
'text-foreground-primary': hasArgs,
},
)}
style={{
top: `${(props.path.length - 1) * 32}px`,
}}
size="sm"
>
<ChevronDownIcon
className={cn('text-muted-foreground size-4 transition-all', {
'-rotate-90': !isOpen,
})}
/>
<Checkbox onClick={e => e.stopPropagation()} checked={hasArgs} disabled />
<CuboidIcon className="size-4 text-rose-400" />
[arguments]
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="border-border ml-4 flex flex-col border-l pl-1">
{args.map(arg => (
<BuilderArgument
key={arg.name}
field={arg}
path={[...props.path]}
operation={operation}
/>
))}
</CollapsibleContent>
</Collapsible>
)}
{fields?.map(child => (
<BuilderField
key={child.name}
field={child}
path={[...props.path, child.name]}
openPaths={props.openPaths}
setOpenPaths={props.setOpenPaths}
isReadOnly={props.isReadOnly}
operation={operation}
/>
))}
</div>
)}
</CollapsibleContent>
</Collapsible>
);
};
export const BuilderField = (props: {
field: GraphQLField<unknown, unknown, unknown>;
path: string[];
openPaths: string[];
setOpenPaths: (openPaths: string[]) => void;
operation?: LaboratoryOperation | null;
isReadOnly?: boolean;
}) => {
const { schema } = useLaboratory();
const type = schema?.getType(props.field.type.toString().replace(/\[|\]|!/g, ''));
if (
!type ||
type instanceof GraphQLScalarType ||
type instanceof GraphQLEnumType ||
type instanceof GraphQLUnionType
) {
return (
<BuilderScalarField
field={props.field}
path={props.path}
openPaths={props.openPaths}
setOpenPaths={props.setOpenPaths}
isReadOnly={props.isReadOnly}
operation={props.operation}
/>
);
}
return (
<BuilderObjectField
field={props.field}
path={props.path}
openPaths={props.openPaths}
setOpenPaths={props.setOpenPaths}
isReadOnly={props.isReadOnly}
operation={props.operation}
/>
);
};
export const Builder = (props: {
operation?: LaboratoryOperation | null;
isReadOnly?: boolean;
}) => {
const { schema, activeOperation } = useLaboratory();
const [openPaths, setOpenPaths] = useState<string[]>([]);
const operation = useMemo(() => {
return props.operation ?? activeOperation ?? null;
}, [props.operation, activeOperation]);
useEffect(() => {
if (schema) {
const newOpenPaths = getOpenPaths(operation?.query ?? '');
if (newOpenPaths.length > 0) {
setOpenPaths(newOpenPaths);
setTabValue(newOpenPaths[0]);
}
}
}, [schema, operation?.query]);
const queryFields = useMemo(
() => Object.values(schema?.getQueryType()?.getFields?.() ?? {}),
[schema],
);
const mutationFields = useMemo(
() => Object.values(schema?.getMutationType()?.getFields?.() ?? {}),
[schema],
);
const subscriptionFields = useMemo(
() => Object.values(schema?.getSubscriptionType()?.getFields?.() ?? {}),
[schema],
);
const [tabValue, setTabValue] = useState<string>('query');
return (
<div className="bg-card flex size-full flex-col overflow-hidden">
<div className="flex items-center px-3 pt-3">
<span className="text-base font-medium">Builder</span>
<div className="ml-auto flex items-center">
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={() => setOpenPaths([])}
variant="ghost"
size="icon-sm"
className="size-6 rounded-sm !p-1"
disabled={openPaths.length === 0}
>
<CopyMinusIcon className="text-muted-foreground size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Collapse all</TooltipContent>
</Tooltip>
</div>
</div>
<div className="flex-1 overflow-hidden">
{schema ? (
<Tabs
key={operation?.id}
value={tabValue}
onValueChange={setTabValue}
className="flex size-full flex-col gap-0"
>
<div className="border-border flex items-center border-b p-3">
<TabsList className="w-full">
<TabsTrigger value="query" disabled={queryFields.length === 0} className="text-xs">
Query
</TabsTrigger>
<TabsTrigger
value="mutation"
disabled={mutationFields.length === 0}
className="text-xs"
>
Mutation
</TabsTrigger>
<TabsTrigger
value="subscription"
disabled={subscriptionFields.length === 0}
className="text-xs"
>
Subscription
</TabsTrigger>
</TabsList>
</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]}
openPaths={openPaths}
setOpenPaths={setOpenPaths}
isReadOnly={props.isReadOnly}
operation={operation}
/>
))}
</TabsContent>
<TabsContent value="mutation">
{mutationFields?.map(field => (
<BuilderField
key={field.name}
field={field}
path={['mutation', field.name]}
openPaths={openPaths}
setOpenPaths={setOpenPaths}
isReadOnly={props.isReadOnly}
operation={operation}
/>
))}
</TabsContent>
<TabsContent value="subscription">
{subscriptionFields?.map(field => (
<BuilderField
key={field.name}
field={field}
path={['subscription', field.name]}
openPaths={openPaths}
setOpenPaths={setOpenPaths}
isReadOnly={props.isReadOnly}
operation={operation}
/>
))}
</TabsContent>
</div>
<ScrollBar className="relative z-50" />
<ScrollBar orientation="horizontal" className="relative z-50" />
</ScrollArea>
</div>
</Tabs>
) : (
<Empty className="h-96 w-full !px-0">
<EmptyHeader>
<EmptyMedia variant="icon">
<FolderIcon className="text-muted-foreground size-6" />
</EmptyMedia>
<EmptyTitle className="text-base">No endpoint selected</EmptyTitle>
<EmptyDescription className="text-xs">
You haven't selected any endpoint yet. Get started by selecting an endpoint.
</EmptyDescription>
</EmptyHeader>
</Empty>
)}
</div>
</div>
);
};

View file

@ -0,0 +1,360 @@
import { useMemo, useState } from 'react';
import {
FolderIcon,
FolderOpenIcon,
FolderPlusIcon,
SearchIcon,
TrashIcon,
XIcon,
} from 'lucide-react';
import { GraphQLIcon } from '@/laboratory/components/icons';
import { useLaboratory } from '@/laboratory/components/laboratory/context';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/laboratory/components/ui/alert-dialog';
import { Button } from '@/laboratory/components/ui/button';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/laboratory/components/ui/collapsible';
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/laboratory/components/ui/empty';
import { Input } from '@/laboratory/components/ui/input';
import { ScrollArea, ScrollBar } from '@/laboratory/components/ui/scroll-area';
import { Tooltip, TooltipContent } from '@/laboratory/components/ui/tooltip';
import type {
LaboratoryCollection,
LaboratoryCollectionOperation,
} from '@/laboratory/lib/collections';
import { cn } from '@/laboratory/lib/utils';
import { TooltipTrigger } from '@radix-ui/react-tooltip';
export const CollectionItem = (props: { collection: LaboratoryCollection }) => {
const {
activeOperation,
operations,
addOperation,
setActiveOperation,
deleteCollection,
deleteOperationFromCollection,
addTab,
setActiveTab,
checkPermissions,
} = useLaboratory();
const [isOpen, setIsOpen] = useState(false);
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className="bg-background group sticky top-0 w-full justify-start px-2"
size="sm"
>
{isOpen ? (
<FolderOpenIcon className="text-muted-foreground size-4" />
) : (
<FolderIcon className="text-muted-foreground size-4" />
)}
{props.collection.name}
{checkPermissions?.('collections:delete') && (
<Tooltip>
<TooltipTrigger asChild>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="link"
className="text-muted-foreground hover:text-destructive ml-auto !p-1 !pr-0 opacity-0 transition-opacity group-hover:opacity-100"
onClick={e => {
e.stopPropagation();
}}
>
<TrashIcon />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Are you sure you want to delete collection?
</AlertDialogTitle>
<AlertDialogDescription>
{props.collection.name} will be permanently deleted. All operations in this
collection will be deleted as well.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction asChild>
<Button
variant="destructive"
onClick={e => {
e.stopPropagation();
deleteCollection(props.collection.id);
}}
>
Delete
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</TooltipTrigger>
<TooltipContent>Delete collection</TooltipContent>
</Tooltip>
)}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className={cn('border-border ml-4 flex flex-col gap-1 border-l pl-2')}>
{isOpen &&
props.collection.operations.map(operation => {
const isActive = activeOperation?.id === operation.id;
return (
<Button
key={operation.name}
variant="ghost"
className={cn('group w-full justify-start gap-2 px-2', {
'bg-accent dark:bg-accent/50': isActive,
})}
size="sm"
onClick={() => {
if (operations.some(o => o.id === operation.id)) {
setActiveOperation(operation.id);
} else {
const newOperation = addOperation(operation);
const tab = addTab({
type: 'operation',
data: newOperation,
});
setActiveTab(tab);
}
}}
>
<GraphQLIcon className="size-4 text-pink-500" />
{operation.name}
{checkPermissions?.('collectionsOperations:delete') && (
<Tooltip>
<TooltipTrigger asChild>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="link"
className="text-muted-foreground hover:text-destructive ml-auto !p-1 !pr-0 opacity-0 transition-opacity group-hover:opacity-100"
onClick={e => {
e.stopPropagation();
}}
>
<TrashIcon />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Are you sure you want to delete operation {operation.name}?
</AlertDialogTitle>
<AlertDialogDescription>
{operation.name} will be permanently deleted.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction asChild>
<Button
variant="destructive"
onClick={e => {
e.stopPropagation();
deleteOperationFromCollection(props.collection.id, operation.id);
}}
>
Delete
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</TooltipTrigger>
<TooltipContent>Delete operation</TooltipContent>
</Tooltip>
)}
</Button>
);
})}
</CollapsibleContent>
</Collapsible>
);
};
export interface CollectionsSearchResultItem extends LaboratoryCollectionOperation {
parent: LaboratoryCollection;
}
export const CollectionsSearchResult = (props: { items: CollectionsSearchResultItem[] }) => {
const { activeOperation, operations, addOperation, setActiveOperation, addTab, setActiveTab } =
useLaboratory();
return (
<div className="flex flex-col gap-1">
{props.items.map(operation => {
const isActive = activeOperation?.id === operation.id;
return (
<Button
key={operation.name}
variant="ghost"
className={cn('group w-full justify-start gap-2 px-2', {
'bg-accent dark:bg-accent/50': isActive,
})}
size="sm"
onClick={() => {
if (operations.some(o => o.id === operation.id)) {
setActiveOperation(operation.id);
} else {
const newOperation = addOperation(operation);
const tab = addTab({
type: 'operation',
data: newOperation,
});
setActiveTab(tab);
}
}}
>
<GraphQLIcon className="size-4 text-pink-500" />
<span className="text-muted-foreground truncate">{operation.parent.name}</span>
<span className="text-muted-foreground">{' / '}</span>
{operation.name}
</Button>
);
})}
</div>
);
};
export const Collections = () => {
const [search, setSearch] = useState('');
const { collections, openAddCollectionDialog, checkPermissions } = useLaboratory();
const searchResults = useMemo(() => {
return collections
.reduce((acc, collection) => {
return [
...acc,
...collection.operations.map(operation => ({
...operation,
parent: collection,
})),
];
}, [] as CollectionsSearchResultItem[])
.filter(item => {
return item.name.toLowerCase().includes(search.toLowerCase());
});
}, [collections, search]);
return (
<div className="grid size-full grid-rows-[auto_1fr] pb-0">
<div className="flex flex-col">
<div className="flex items-center gap-2 p-3 pb-0">
<span className="text-base font-medium">Collections</span>
<div className="ml-auto flex items-center gap-2">
{checkPermissions?.('collections:create') && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="size-6 rounded-sm !p-1"
onClick={openAddCollectionDialog}
>
<FolderPlusIcon className="text-primary size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Add collection</TooltipContent>
</Tooltip>
)}
</div>
</div>
<div className="border-border relative border-b p-3">
<SearchIcon className="text-muted-foreground absolute left-5 top-1/2 size-4 -translate-y-1/2" />
<Input
type="text"
placeholder="Search..."
className={cn('px-7')}
value={search}
onChange={e => setSearch(e.target.value)}
/>
{search.length > 0 && (
<Button
variant="ghost"
size="icon-sm"
className="absolute right-5 top-1/2 size-6 -translate-y-1/2 rounded-sm !p-1"
onClick={() => setSearch('')}
>
<XIcon className="text-muted-foreground size-4" />
</Button>
)}
</div>
</div>
<div className="size-full overflow-hidden">
<ScrollArea className="size-full">
<div className="flex flex-col gap-1 p-3">
{search.length > 0 ? (
searchResults.length > 0 ? (
<CollectionsSearchResult items={searchResults} />
) : (
<Empty className="w-full !px-0">
<EmptyHeader>
<EmptyMedia variant="icon">
<SearchIcon className="text-muted-foreground size-6" />
</EmptyMedia>
<EmptyTitle className="text-base">No results found</EmptyTitle>
<EmptyDescription className="text-xs">
No collections found matching your search.
</EmptyDescription>
</EmptyHeader>
</Empty>
)
) : collections.length > 0 ? (
collections.map(item => <CollectionItem key={item.id} collection={item} />)
) : (
<Empty className="w-full !px-0">
<EmptyHeader>
<EmptyMedia variant="icon">
<FolderIcon className="text-muted-foreground size-6" />
</EmptyMedia>
<EmptyTitle className="text-base">No collections yet</EmptyTitle>
<EmptyDescription className="text-xs">
You haven't created any collections yet. Get started by adding your first
collection.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button variant="secondary" size="sm" onClick={openAddCollectionDialog}>
Add collection
</Button>
</EmptyContent>
</Empty>
)}
</div>
<ScrollBar />
</ScrollArea>
</div>
</div>
);
};

View file

@ -0,0 +1,166 @@
import { useEffect, useState } from 'react';
import { FilePlus2Icon, FolderPlusIcon, PlayIcon, RefreshCcwIcon, ServerIcon } from 'lucide-react';
import { useLaboratory } from '@/laboratory/components/laboratory/context';
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
} from '@/laboratory/components/ui/command';
export function Command(props: { open?: boolean; onOpenChange?: (open: boolean) => void }) {
const {
endpoint,
openAddCollectionDialog,
addOperation,
runActiveOperation,
fetchSchema,
addTab,
setActiveTab,
tabs,
preflight,
env,
} = useLaboratory();
const [open, setOpen] = useState(props.open ?? false);
useEffect(() => {
setOpen(props.open ?? false);
}, [props.open]);
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === 'j' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
const newOpen = !open;
setOpen(newOpen);
props.onOpenChange?.(newOpen);
}
};
document.addEventListener('keydown', down);
return () => document.removeEventListener('keydown', down);
}, [open, props]);
return (
<>
<CommandDialog
open={open}
onOpenChange={newOpen => {
setOpen(newOpen);
props.onOpenChange?.(newOpen);
}}
>
<CommandInput placeholder="Type a command or search..." />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup heading="Operations">
<CommandItem
disabled={!endpoint}
onSelect={() => {
void runActiveOperation(endpoint!);
setOpen(false);
}}
>
<PlayIcon />
<span>Run operation</span>
<CommandShortcut></CommandShortcut>
</CommandItem>
<CommandItem
onSelect={() => {
const newOperation = addOperation({
name: '',
query: '',
variables: '',
headers: '',
extensions: '',
});
const tab = addTab({
type: 'operation',
data: newOperation,
});
setActiveTab(tab);
setOpen(false);
}}
>
<FilePlus2Icon />
<span>Add operation</span>
</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Collections">
<CommandItem
onSelect={() => {
openAddCollectionDialog?.();
setOpen(false);
}}
>
<FolderPlusIcon />
<span>Add collection</span>
</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Schema">
{/* <CommandItem
onSelect={() => {
openUpdateEndpointDialog?.();
setOpen(false);
}}
>
<ServerIcon />
<span>Update endpoint</span>
</CommandItem> */}
<CommandItem
onSelect={() => {
fetchSchema();
setOpen(false);
}}
>
<RefreshCcwIcon />
<span>Refetch schema</span>
</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Settings">
<CommandItem
onSelect={() => {
const tab =
tabs.find(t => t.type === 'env') ??
addTab({
type: 'env',
data: env ?? { variables: {} },
});
setActiveTab(tab);
setOpen(false);
}}
>
<ServerIcon />
<span>Open Environment Variables</span>
</CommandItem>
<CommandItem
onSelect={() => {
const tab =
tabs.find(t => t.type === 'preflight') ??
addTab({
type: 'preflight',
data: preflight ?? { script: '' },
});
setActiveTab(tab);
setOpen(false);
}}
>
<RefreshCcwIcon />
<span>Open Preflight Script</span>
</CommandItem>
</CommandGroup>
</CommandList>
</CommandDialog>
</>
);
}

View file

@ -0,0 +1,175 @@
import { createContext, useContext } from 'react';
import {
type LaboratoryCollection,
type LaboratoryCollectionOperation,
type LaboratoryCollectionsActions,
type LaboratoryCollectionsState,
} from '@/laboratory/lib/collections';
import {
type LaboratoryEndpointActions,
type LaboratoryEndpointState,
} from '@/laboratory/lib/endpoint';
import type { LaboratoryEnv, LaboratoryEnvActions, LaboratoryEnvState } from '@/laboratory/lib/env';
import type {
LaboratoryHistory,
LaboratoryHistoryActions,
LaboratoryHistoryState,
} from '@/laboratory/lib/history';
import {
type LaboratoryOperation,
type LaboratoryOperationsActions,
type LaboratoryOperationsState,
} from '@/laboratory/lib/operations';
import type {
LaboratoryPreflight,
LaboratoryPreflightActions,
LaboratoryPreflightState,
} from '@/laboratory/lib/preflight';
import type {
LaboratorySettings,
LaboratorySettingsActions,
LaboratorySettingsState,
} from '@/laboratory/lib/settings';
import type {
LaboratoryTab,
LaboratoryTabsActions,
LaboratoryTabsState,
} from '@/laboratory/lib/tabs';
import type {
LaboratoryTest,
LaboratoryTestActions,
LaboratoryTestState,
} from '@/laboratory/lib/tests';
type LaboratoryContextState = LaboratoryCollectionsState &
LaboratoryEndpointState &
LaboratoryOperationsState &
LaboratoryHistoryState &
LaboratoryTabsState &
LaboratoryPreflightState &
LaboratoryEnvState &
LaboratorySettingsState &
LaboratoryTestState & {
isFullScreen?: boolean;
};
type LaboratoryContextActions = LaboratoryCollectionsActions &
LaboratoryEndpointActions &
LaboratoryOperationsActions &
LaboratoryHistoryActions &
LaboratoryTabsActions &
LaboratoryPreflightActions &
LaboratoryEnvActions &
LaboratorySettingsActions &
LaboratoryTestActions & {
openAddCollectionDialog?: () => void;
openUpdateEndpointDialog?: () => void;
openAddTestDialog?: () => void;
openPreflightPromptModal?: (props: {
placeholder: string;
defaultValue?: string;
onSubmit?: (value: string | null) => void;
}) => void;
goToFullScreen?: () => void;
exitFullScreen?: () => void;
checkPermissions?: (
permission: `${keyof LaboratoryPermissions & string}:${keyof LaboratoryPermission & string}`,
) => boolean;
};
const LaboratoryContext = createContext<LaboratoryContextState & LaboratoryContextActions>(
{} as LaboratoryContextState & LaboratoryContextActions,
);
export const useLaboratory = () => {
return useContext(LaboratoryContext);
};
export interface LaboratoryPermission {
read?: boolean;
create?: boolean;
update?: boolean;
delete?: boolean;
}
export interface LaboratoryPermissions {
preflight?: Partial<LaboratoryPermission>;
collections?: Partial<LaboratoryPermission>;
collectionsOperations?: Partial<LaboratoryPermission>;
}
export interface LaboratoryApi {
defaultEndpoint?: string | null;
onEndpointChange?: (endpoint: string | null) => void;
defaultCollections?: LaboratoryCollection[];
onCollectionsChange?: (collections: LaboratoryCollection[]) => void;
onCollectionCreate?: (collection: LaboratoryCollection) => void;
onCollectionUpdate?: (collection: LaboratoryCollection) => void;
onCollectionDelete?: (collection: LaboratoryCollection) => void;
onCollectionOperationCreate?: (
collection: LaboratoryCollection,
operation: LaboratoryCollectionOperation,
) => void;
onCollectionOperationUpdate?: (
collection: LaboratoryCollection,
operation: LaboratoryCollectionOperation,
) => void;
onCollectionOperationDelete?: (
collection: LaboratoryCollection,
operation: LaboratoryCollectionOperation,
) => void;
defaultOperations?: LaboratoryOperation[];
defaultActiveOperationId?: string;
onOperationsChange?: (operations: LaboratoryOperation[]) => void;
onActiveOperationIdChange?: (operationId: string) => void;
onOperationCreate?: (operation: LaboratoryOperation) => void;
onOperationUpdate?: (operation: LaboratoryOperation) => void;
onOperationDelete?: (operation: LaboratoryOperation) => void;
defaultHistory?: LaboratoryHistory[];
onHistoryChange?: (history: LaboratoryHistory[]) => void;
onHistoryCreate?: (history: LaboratoryHistory) => void;
onHistoryUpdate?: (history: LaboratoryHistory) => void;
onHistoryDelete?: (history: LaboratoryHistory) => void;
openAddCollectionDialog?: () => void;
openUpdateEndpointDialog?: () => void;
openAddTestDialog?: () => void;
openPreflightPromptModal?: (props: {
placeholder: string;
defaultValue?: string;
onSubmit?: (value: string | null) => void;
}) => void;
isFullScreen?: boolean;
goToFullScreen?: () => void;
exitFullScreen?: () => void;
defaultPreflight?: LaboratoryPreflight | null;
onPreflightChange?: (preflight: LaboratoryPreflight | null) => void;
defaultTabs?: LaboratoryTab[];
onTabsChange?: (tabs: LaboratoryTab[]) => void;
defaultActiveTabId?: string | null;
onActiveTabIdChange?: (tabId: string | null) => void;
defaultEnv?: LaboratoryEnv | null;
onEnvChange?: (env: LaboratoryEnv | null) => void;
defaultSettings?: LaboratorySettings | null;
onSettingsChange?: (settings: LaboratorySettings | null) => void;
defaultTests?: LaboratoryTest[];
onTestsChange?: (tests: LaboratoryTest[]) => void;
permissions?: LaboratoryPermissions;
checkPermissions?: (
permission: `${keyof LaboratoryPermissions & string}:${keyof LaboratoryPermission & string}`,
) => boolean;
}
export type LaboratoryContextProps = LaboratoryContextState &
LaboratoryContextActions &
LaboratoryApi;
export const LaboratoryProvider = (props: React.PropsWithChildren<LaboratoryContextProps>) => {
return (
<LaboratoryContext.Provider
value={{
...props,
}}
>
{props.children}
</LaboratoryContext.Provider>
);
};

View file

@ -0,0 +1,201 @@
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
import * as monaco from 'monaco-editor';
import { initializeMode } from 'monaco-graphql/initializeMode';
import { useLaboratory } from '@/laboratory/components/laboratory/context';
import MonacoEditor, { loader } from '@monaco-editor/react';
if (typeof window !== 'undefined') {
(window as any).monaco = monaco;
}
loader.config({ monaco });
monaco.languages.register({ id: 'dotenv' });
const darkTheme: monaco.editor.IStandaloneThemeData = {
base: 'vs-dark',
inherit: true,
rules: [
{ token: '', foreground: 'F8F9FA', background: 'fffffe' },
{ token: 'invalid', foreground: 'cd3131' },
{ token: 'emphasis', fontStyle: 'italic' },
{ token: 'strong', fontStyle: 'bold' },
{ token: 'variable', foreground: '001188' },
{ token: 'variable.predefined', foreground: '4864AA' },
{ token: 'constant', foreground: 'dd0000' },
{ token: 'comment', foreground: '15803d' },
{ token: 'number', foreground: 'fde68a' },
{ token: 'number.hex', foreground: '3030c0' },
{ token: 'regexp', foreground: '800000' },
{ token: 'annotation', foreground: '808080' },
{ token: 'type', foreground: 'fde68a' },
{ token: 'delimiter', foreground: '6E757C' },
{ token: 'delimiter.html', foreground: '383838' },
{ token: 'delimiter.xml', foreground: 'facc15' },
{ token: 'tag', foreground: '800000' },
{ token: 'tag.id.jade', foreground: '4F76AC' },
{ token: 'tag.class.jade', foreground: '4F76AC' },
{ token: 'meta.scss', foreground: '800000' },
{ token: 'metatag', foreground: 'e00000' },
{ token: 'metatag.content.html', foreground: 'FF0000' },
{ token: 'metatag.html', foreground: '808080' },
{ token: 'metatag.xml', foreground: '808080' },
{ token: 'metatag.php', fontStyle: 'bold' },
{ token: 'key', foreground: '93c5fd' },
{ token: 'string.key.json', foreground: '93c5fd' },
{ token: 'string.value.json', foreground: 'fdba74' },
{ token: 'attribute.name', foreground: 'FF0000' },
{ token: 'attribute.value', foreground: '34d399' },
{ token: 'attribute.value.number', foreground: 'fdba74' },
{ token: 'attribute.value.unit', foreground: 'fdba74' },
{ token: 'attribute.value.html', foreground: 'facc15' },
{ token: 'attribute.value.xml', foreground: 'facc15' },
{ token: 'string', foreground: '2dd4bf' },
{ token: 'string.html', foreground: 'facc15' },
{ token: 'string.sql', foreground: 'FF0000' },
{ token: 'string.yaml', foreground: '34d399' },
{ token: 'keyword', foreground: '60a5fa' },
{ token: 'keyword.json', foreground: '34d399' },
{ token: 'keyword.flow', foreground: 'AF00DB' },
{ token: 'keyword.flow.scss', foreground: 'facc15' },
{ token: 'operator.scss', foreground: '666666' },
{ token: 'operator.sql', foreground: '778899' },
{ token: 'operator.swift', foreground: '666666' },
{ token: 'predefined.sql', foreground: 'FF00FF' },
],
colors: {
'editor.foreground': '#f6f8fa',
'editor.background': '#18181b',
'editor.selectionBackground': '#2A2F34',
'editor.inactiveSelectionBackground': '#2A2F34',
'editor.lineHighlightBackground': '#2A2F34',
'editorCursor.foreground': '#ffffff',
'editorWhitespace.foreground': '#6a737d',
'editorIndentGuide.background': '#6E757C',
'editorIndentGuide.activeBackground': '#CFD4D9',
'editor.selectionHighlightBorder': '#2A2F34',
},
};
monaco.editor.defineTheme('hive-laboratory-dark', darkTheme);
monaco.languages.setMonarchTokensProvider('dotenv', {
tokenizer: {
root: [
[/^\s*#.*$/, 'comment'],
[/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/, 'key', '@value'],
],
value: [
[/"([^"\\]|\\.)*$/, 'string', '@pop'],
[/"([^"\\]|\\.)*"/, 'string', '@pop'],
[/'([^'\\]|\\.)*$/, 'string', '@pop'],
[/'([^'\\]|\\.)*'/, 'string', '@pop'],
[/[^#\n]+/, 'string', '@pop'],
],
},
});
export const Editor = forwardRef<
{
setValue: (value: string) => void;
},
React.ComponentProps<typeof MonacoEditor> & {
uri?: monaco.Uri;
variablesUri?: monaco.Uri;
extraLibs?: string[];
}
>((props, ref) => {
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
const { introspection } = useLaboratory();
useEffect(() => {
if (introspection) {
initializeMode({
schemas: [
{
introspectionJSON: introspection,
uri: 'schema.graphql',
},
],
diagnosticSettings:
props.uri && props.variablesUri
? {
validateVariablesJSON: {
[props.uri.toString()]: [props.variablesUri.toString()],
},
jsonDiagnosticSettings: {
allowComments: true, // allow json, parse with a jsonc parser to make requests
},
}
: undefined,
});
}
}, [introspection, props.uri, props.variablesUri]);
useEffect(() => {
if (props.extraLibs) {
for (const lib of props.extraLibs) {
monaco.languages.typescript.typescriptDefaults.setCompilerOptions({
target: monaco.languages.typescript.ScriptTarget.ESNext, // supports top-level await
module: monaco.languages.typescript.ModuleKind.ESNext, // treat file as module
allowNonTsExtensions: true,
allowJs: true,
lib: ['esnext', 'webworker'], // if running in sandbox
});
monaco.languages.typescript.typescriptDefaults.addExtraLib(
lib,
'file:///hive-lab-globals.d.ts',
);
}
}
}, [props.extraLibs]);
useImperativeHandle(
ref,
() => ({
setValue: (value: string) => {
if (editorRef.current) {
editorRef.current.setValue(value);
}
},
}),
[],
);
return (
<div className="size-full">
<MonacoEditor
className="size-full"
{...props}
theme="hive-laboratory-dark"
onMount={editor => {
editorRef.current = editor;
}}
options={{
...props.options,
padding: {
top: 16,
},
fontFamily:
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
minimap: {
enabled: false,
},
automaticLayout: true,
tabSize: 2,
}}
defaultPath={props.uri?.toString()}
/>
</div>
);
});

View file

@ -0,0 +1,36 @@
import { useLaboratory } from '@/laboratory/components/laboratory/context';
import { Editor } from '@/laboratory/components/laboratory/editor';
export const Env = () => {
const { env, setEnv } = useLaboratory();
return (
<div className="bg-card size-full">
<Editor
defaultValue={Object.entries(env?.variables ?? {})
.map(([key, value]) => `${key}=${value}`)
.join('\n')}
onChange={value => {
setEnv({
variables: Object.fromEntries(
value
?.split('\n')
.filter(line => line.trim() && !line.trim().startsWith('#'))
.map(line => {
const parts = line.split(/=(.*)/s);
return [parts[0].trim(), (parts[1] ?? '').trim()];
}) ?? [],
),
});
}}
language="dotenv"
options={{
scrollbar: {
horizontal: 'hidden',
},
}}
/>
</div>
);
};

View file

@ -0,0 +1,21 @@
import { useMemo } from 'react';
import { useLaboratory } from '@/laboratory/components/laboratory/context';
import { Operation } from '@/laboratory/components/laboratory/operation';
export const HistoryItem = () => {
const { activeTab, history } = useLaboratory();
const historyItem = useMemo(() => {
if (activeTab?.type !== 'history') {
return null;
}
return history.find(h => h.id === activeTab.data.id) ?? null;
}, [history, activeTab]);
if (!historyItem) {
return null;
}
return <Operation operation={historyItem.operation} historyItem={historyItem} />;
};

View file

@ -0,0 +1,305 @@
import { useCallback, useMemo, useState } from 'react';
import { format } from 'date-fns';
import { ClockIcon, FolderClockIcon, FolderOpenIcon, HistoryIcon, TrashIcon } from 'lucide-react';
import { useLaboratory } from '@/laboratory/components/laboratory/context';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/laboratory/components/ui/alert-dialog';
import { Button } from '@/laboratory/components/ui/button';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/laboratory/components/ui/collapsible';
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/laboratory/components/ui/empty';
import { ScrollArea, ScrollBar } from '@/laboratory/components/ui/scroll-area';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/laboratory/components/ui/tooltip';
import type { LaboratoryHistory, LaboratoryHistoryRequest } from '@/laboratory/lib/history';
import { cn } from '@/laboratory/lib/utils';
export const HistoryOperationItem = (props: { historyItem: LaboratoryHistoryRequest }) => {
const { activeTab, addTab, setActiveTab, deleteHistory } = useLaboratory();
const isActive = useMemo(() => {
return activeTab?.type === 'history' && activeTab.data.id === props.historyItem.id;
}, [activeTab, props.historyItem]);
const isError = useMemo(() => {
return (
props.historyItem.status < 200 ||
props.historyItem.status >= 300 ||
('response' in props.historyItem && JSON.parse(props.historyItem.response).errors)
);
}, [props.historyItem]);
return (
<Button
variant="ghost"
size="sm"
className={cn('bg-background group sticky top-0 w-full justify-start px-2', {
'bg-accent dark:bg-accent/50': isActive,
})}
onClick={() => {
setActiveTab(
addTab({
type: 'history',
data: props.historyItem,
readOnly: true,
}),
);
}}
>
<HistoryIcon
className={cn('size-4 text-indigo-400', {
'text-green-500': props.historyItem.status >= 200 && props.historyItem.status < 300,
'text-red-500': isError,
})}
/>
<span className="text-muted-foreground">
{format(new Date(props.historyItem.createdAt), 'HH:mm')}
</span>
<div className="truncate">{props.historyItem.operation.name || 'Untitled'}</div>
<div className="ml-auto flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="link"
className="text-muted-foreground hover:text-destructive ml-auto !p-1 !pr-0 opacity-0 transition-opacity group-hover:opacity-100"
onClick={e => {
e.stopPropagation();
}}
>
<TrashIcon />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure you want to delete history?</AlertDialogTitle>
<AlertDialogDescription>
This history operation will be permanently deleted.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction asChild>
<Button
variant="destructive"
onClick={e => {
e.stopPropagation();
deleteHistory(props.historyItem.id);
}}
>
Delete
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</TooltipTrigger>
<TooltipContent>Delete history</TooltipContent>
</Tooltip>
</div>
</Button>
);
};
export const HistoryGroup = (props: { group: { date: string; items: LaboratoryHistory[] } }) => {
const { deleteHistoryByDay } = useLaboratory();
const [isOpen, setIsOpen] = useState(false);
return (
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className="bg-background group sticky top-0 w-full justify-start px-2"
size="sm"
>
{isOpen ? (
<FolderOpenIcon className="text-muted-foreground size-4" />
) : (
<FolderClockIcon className="text-muted-foreground size-4" />
)}
{props.group.date}
<Tooltip>
<TooltipTrigger asChild>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="link"
className="text-muted-foreground hover:text-destructive ml-auto !p-1 !pr-0 opacity-0 transition-opacity group-hover:opacity-100"
onClick={e => {
e.stopPropagation();
}}
>
<TrashIcon />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure you want to delete history?</AlertDialogTitle>
<AlertDialogDescription>
All history for {props.group.date} will be permanently deleted.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction asChild>
<Button
variant="destructive"
onClick={e => {
e.stopPropagation();
deleteHistoryByDay(props.group.date);
}}
>
Delete
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</TooltipTrigger>
<TooltipContent>Delete history</TooltipContent>
</Tooltip>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className={cn('border-border ml-4 flex flex-col gap-1 border-l pl-2')}>
{props.group.items.map(h => {
return <HistoryOperationItem key={h.id} historyItem={h as LaboratoryHistoryRequest} />;
})}
</CollapsibleContent>
</Collapsible>
);
};
export const History = () => {
const { history, deleteAllHistory, tabs, setTabs, setActiveTab } = useLaboratory();
const historyItems = useMemo(() => {
return history.sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
}, [history]);
const goupedByDate = useMemo(() => {
return historyItems.reduce(
(acc, h) => {
const date = format(new Date(h.createdAt), 'dd MMM yyyy');
let item = acc.find(i => i.date === date);
if (!item) {
item = { date, items: [] };
acc.push(item);
}
item.items.push(h);
return acc;
},
[] as { date: string; items: LaboratoryHistory[] }[],
);
}, [historyItems]);
const handleDeleteAllHistory = useCallback(() => {
deleteAllHistory();
setTabs(tabs.filter(t => t.type !== 'history'));
const newTab = tabs.find(t => t.type !== 'history');
if (newTab) {
setActiveTab(newTab);
}
}, [deleteAllHistory, setTabs, tabs, setActiveTab]);
return (
<div className="grid size-full grid-rows-[auto_1fr] pb-0">
<div className="border-border flex h-12 items-center gap-2 border-b p-3">
<span className="text-base font-medium">History</span>
<div className="ml-auto flex items-center">
<Tooltip>
<TooltipTrigger asChild>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-destructive size-6 rounded-sm !p-1"
disabled={history.length === 0}
>
<TrashIcon className="size-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Are you sure you want to delete all history?
</AlertDialogTitle>
<AlertDialogDescription>
All history will be permanently deleted.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction asChild>
<Button
variant="destructive"
onClick={e => {
e.stopPropagation();
handleDeleteAllHistory();
}}
>
Delete
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</TooltipTrigger>
<TooltipContent>Delete all</TooltipContent>
</Tooltip>
</div>
</div>
<div className="size-full overflow-hidden">
<ScrollArea className="size-full">
<div className="flex flex-col gap-1 p-3">
{goupedByDate.length > 0 ? (
goupedByDate.map(group => {
return <HistoryGroup key={group.date} group={group} />;
})
) : (
<Empty className="w-full !px-0">
<EmptyHeader>
<EmptyMedia variant="icon">
<ClockIcon className="text-muted-foreground size-6" />
</EmptyMedia>
<EmptyTitle className="text-base">No history yet</EmptyTitle>
<EmptyDescription className="text-xs">
You haven't run any operations yet. Get started by running your first operation.
</EmptyDescription>
</EmptyHeader>
</Empty>
)}
</div>
<ScrollBar />
</ScrollArea>
</div>
</div>
);
};

View file

@ -0,0 +1,739 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { FileIcon, FoldersIcon, HistoryIcon, SettingsIcon } from 'lucide-react';
import * as z from 'zod';
import { Collections } from '@/laboratory/components/laboratory/collections';
import { Command } from '@/laboratory/components/laboratory/command';
import {
LaboratoryPermission,
LaboratoryPermissions,
LaboratoryProvider,
useLaboratory,
type LaboratoryApi,
} from '@/laboratory/components/laboratory/context';
import { Env } from '@/laboratory/components/laboratory/env';
import { History } from '@/laboratory/components/laboratory/history';
import { HistoryItem } from '@/laboratory/components/laboratory/history-item';
import { Operation } from '@/laboratory/components/laboratory/operation';
import { Preflight } from '@/laboratory/components/laboratory/preflight';
import { Settings } from '@/laboratory/components/laboratory/settings';
import { Tabs } from '@/laboratory/components/laboratory/tabs';
import { Button } from '@/laboratory/components/ui/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/laboratory/components/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from '@/laboratory/components/ui/dropdown-menu';
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/laboratory/components/ui/empty';
import { Field, FieldError, FieldGroup, FieldLabel } from '@/laboratory/components/ui/field';
import { Input } from '@/laboratory/components/ui/input';
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from '@/laboratory/components/ui/resizable';
import { Toaster } from '@/laboratory/components/ui/sonner';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/laboratory/components/ui/tooltip';
import { useCollections } from '@/laboratory/lib/collections';
import { useEndpoint } from '@/laboratory/lib/endpoint';
import { useEnv } from '@/laboratory/lib/env';
import { useHistory } from '@/laboratory/lib/history';
import { useOperations } from '@/laboratory/lib/operations';
import { usePreflight } from '@/laboratory/lib/preflight';
import { useSettings } from '@/laboratory/lib/settings';
import { useTabs } from '@/laboratory/lib/tabs';
import { useTests } from '@/laboratory/lib/tests';
import { cn } from '@/laboratory/lib/utils';
import { useForm } from '@tanstack/react-form';
const addCollectionFormSchema = z.object({
name: z.string().min(1, 'Name is required'),
});
const updateEndpointFormSchema = z.object({
endpoint: z.string().min(1, 'Endpoint is required'),
});
const addTestFormSchema = z.object({
name: z.string().min(1, 'Name is required'),
});
const PreflightPromptModal = (props: {
open: boolean;
onOpenChange: (open: boolean) => void;
placeholder: string;
defaultValue?: string;
onSubmit?: (value: string | null) => void;
}) => {
const form = useForm({
defaultValues: {
value: props.defaultValue || null,
},
validators: {
onSubmit: z.object({
value: z.string().min(1, 'Value is required').nullable(),
}),
},
onSubmit: ({ value }) => {
props.onSubmit?.(value.value || null);
props.onOpenChange(false);
form.reset();
},
});
return (
<Dialog
open={props.open}
onOpenChange={open => {
if (!form.state.isSubmitted) {
void form.handleSubmit();
}
props.onOpenChange(open);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Preflight prompt</DialogTitle>
</DialogHeader>
<DialogDescription>Enter values for the preflight script.</DialogDescription>
<form
id="preflight-prompt-form"
onSubmit={e => {
e.preventDefault();
void form.handleSubmit();
}}
>
<FieldGroup>
<form.Field name="value">
{field => {
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid}>
<Input
id={field.name}
name={field.name}
value={field.state.value || ''}
onBlur={field.handleBlur}
onChange={e => field.handleChange(e.target.value)}
aria-invalid={isInvalid}
placeholder={props.placeholder}
autoComplete="off"
/>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</Field>
);
}}
</form.Field>
</FieldGroup>
</form>
<DialogFooter>
<Button
type="submit"
form="preflight-prompt-form"
onClick={() => {
void form.handleSubmit();
}}
>
Submit
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
const LaboratoryContent = () => {
const { activeTab, addOperation, collections, addTab, setActiveTab, preflight, tabs, env } =
useLaboratory();
const [activePanel, setActivePanel] = useState<
'collections' | 'history' | 'tests' | 'settings' | null
>(collections.length > 0 ? 'collections' : null);
const [commandOpen, setCommandOpen] = useState(false);
const contentNode = useMemo(() => {
switch (activeTab?.type) {
case 'operation':
return <Operation />;
case 'preflight':
return <Preflight />;
case 'env':
return <Env />;
case 'history':
return <HistoryItem />;
case 'settings':
return <Settings />;
default:
return (
<Empty className="w-full !px-0">
<EmptyHeader>
<EmptyMedia variant="icon">
<FileIcon className="text-muted-foreground size-6" />
</EmptyMedia>
<EmptyTitle>No operation selected</EmptyTitle>
<EmptyDescription>
You haven't selected any operation yet. Get started by selecting an operation or add
a new one.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button
size="sm"
onClick={() => {
const operation = addOperation({
name: '',
query: '',
variables: '',
headers: '',
extensions: '',
});
const tab = addTab({
type: 'operation',
data: operation,
});
setActiveTab(tab);
}}
>
Add operation
</Button>
</EmptyContent>
</Empty>
);
}
}, [activeTab?.type, addOperation, addTab, setActiveTab]);
return (
<div className="flex size-full">
<Command open={commandOpen} onOpenChange={setCommandOpen} />
<div className="flex h-full w-12 flex-col">
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
'relative z-10 flex aspect-square h-12 w-full items-center justify-center border-l-2 border-transparent',
{
'border-primary': activePanel === 'collections',
},
)}
>
<Button
variant="ghost"
size="icon"
onClick={() => setActivePanel(activePanel === 'collections' ? null : 'collections')}
className={cn('text-muted-foreground hover:text-foreground', {
'text-foreground': activePanel === 'collections',
})}
>
<FoldersIcon className="size-5" />
</Button>
</div>
</TooltipTrigger>
<TooltipContent side="right">Collections</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
'relative z-10 flex aspect-square h-12 w-full items-center justify-center border-l-2 border-transparent',
{
'border-primary': activePanel === 'history',
},
)}
>
<Button
variant="ghost"
size="icon"
onClick={() => setActivePanel(activePanel === 'history' ? null : 'history')}
className={cn('text-muted-foreground hover:text-foreground', {
'text-foreground': activePanel === 'history',
})}
>
<HistoryIcon className="size-5" />
</Button>
</div>
</TooltipTrigger>
<TooltipContent side="right">History</TooltipContent>
</Tooltip>
<div
className={cn(
'relative z-10 mt-auto flex aspect-square h-12 w-full items-center justify-center border-l-2 border-transparent',
{
'border-primary': activePanel === 'settings',
},
)}
>
<Tooltip>
<DropdownMenu>
<DropdownMenuTrigger>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={() => setActivePanel(activePanel === 'history' ? null : 'history')}
className={cn('text-muted-foreground hover:text-foreground', {
'text-foreground': activePanel === 'history',
})}
>
<SettingsIcon className="size-5" />
</Button>
</TooltipTrigger>
</DropdownMenuTrigger>
<DropdownMenuContent className="mb-2 w-56" align="start" side="right">
<DropdownMenuGroup>
<DropdownMenuItem onSelect={() => setCommandOpen(true)}>
Command Palette...
<DropdownMenuShortcut>J</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => {
const tab =
tabs.find(t => t.type === 'env') ??
addTab({
type: 'env',
data: env ?? { variables: {} },
});
setActiveTab(tab);
}}
>
Environment Variables
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
const tab =
tabs.find(t => t.type === 'preflight') ??
addTab({
type: 'preflight',
data: preflight ?? { script: '' },
});
setActiveTab(tab);
}}
>
Preflight Script
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => {
const tab =
tabs.find(t => t.type === 'settings') ??
addTab({
type: 'settings',
data: {},
});
setActiveTab(tab);
}}
>
Settings
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<TooltipContent side="right">Settings</TooltipContent>
</Tooltip>
</div>
</div>
<ResizablePanelGroup direction="horizontal" className="h-full flex-1">
<ResizablePanel minSize={10} defaultSize={17} hidden={!activePanel} className="border-l">
{activePanel === 'collections' && <Collections />}
{activePanel === 'history' && <History />}
</ResizablePanel>
<ResizableHandle />
<ResizablePanel minSize={10} defaultSize={83} className="flex flex-col">
<div className="w-full">
<Tabs />
</div>
<div className="flex-1 overflow-hidden">{contentNode}</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
);
};
export type LaboratoryProps = LaboratoryApi;
export const Laboratory = (
props: Pick<
LaboratoryProps,
| 'permissions'
| 'defaultEndpoint'
| 'onEndpointChange'
| 'defaultCollections'
| 'onCollectionsChange'
| 'onCollectionCreate'
| 'onCollectionUpdate'
| 'onCollectionDelete'
| 'onCollectionOperationCreate'
| 'onCollectionOperationUpdate'
| 'onCollectionOperationDelete'
| 'defaultOperations'
| 'onOperationsChange'
| 'defaultActiveOperationId'
| 'onActiveOperationIdChange'
| 'onOperationCreate'
| 'onOperationUpdate'
| 'onOperationDelete'
| 'defaultHistory'
| 'onHistoryChange'
| 'onHistoryCreate'
| 'onHistoryUpdate'
| 'onHistoryDelete'
| 'defaultTabs'
| 'onTabsChange'
| 'defaultPreflight'
| 'onPreflightChange'
| 'defaultEnv'
| 'onEnvChange'
| 'defaultActiveTabId'
| 'onActiveTabIdChange'
| 'defaultSettings'
| 'onSettingsChange'
| 'defaultTests'
| 'onTestsChange'
>,
) => {
const checkPermissions = useCallback(
(
permission: `${keyof LaboratoryPermissions & string}:${keyof LaboratoryPermission & string}`,
) => {
const [namespace, action] = permission.split(':');
return (
props.permissions?.[namespace as keyof LaboratoryPermissions]?.[
action as keyof LaboratoryPermission
] ?? true
);
},
[props.permissions],
);
const settingsApi = useSettings(props);
const envApi = useEnv(props);
const preflightApi = usePreflight({
...props,
envApi,
});
const testsApi = useTests(props);
const tabsApi = useTabs(props);
const endpointApi = useEndpoint(props);
const collectionsApi = useCollections({
...props,
tabsApi,
});
const operationsApi = useOperations({
...props,
collectionsApi,
tabsApi,
envApi,
preflightApi,
checkPermissions,
});
const historyApi = useHistory(props);
const [isAddCollectionDialogOpen, setIsAddCollectionDialogOpen] = useState(false);
const [isUpdateEndpointDialogOpen, setIsUpdateEndpointDialogOpen] = useState(false);
const [isAddTestDialogOpen, setIsAddTestDialogOpen] = useState(false);
const openAddCollectionDialog = useCallback(() => {
setIsAddCollectionDialogOpen(true);
}, []);
const openUpdateEndpointDialog = useCallback(() => {
setIsUpdateEndpointDialogOpen(true);
}, []);
const openAddTestDialog = useCallback(() => {
setIsAddTestDialogOpen(true);
}, []);
const addCollectionForm = useForm({
defaultValues: {
name: '',
},
validators: {
onSubmit: addCollectionFormSchema,
},
onSubmit: ({ value }) => {
collectionsApi.addCollection({
name: value.name,
});
setIsAddCollectionDialogOpen(false);
},
});
const updateEndpointForm = useForm({
defaultValues: {
endpoint: endpointApi.endpoint ?? '',
},
validators: {
onSubmit: updateEndpointFormSchema,
},
onSubmit: ({ value }) => {
endpointApi.setEndpoint(value.endpoint);
setIsUpdateEndpointDialogOpen(false);
},
});
const addTestForm = useForm({
defaultValues: {
name: '',
},
validators: {
onSubmit: addTestFormSchema,
},
onSubmit: ({ value }) => {
testsApi.addTest({ name: value.name });
setIsAddTestDialogOpen(false);
},
});
const [isPreflightPromptModalOpen, setIsPreflightPromptModalOpen] = useState(false);
const [preflightPromptModalProps, setPreflightPromptModalProps] = useState<{
placeholder: string;
defaultValue?: string;
onSubmit?: (value: string | null) => void;
}>({
placeholder: '',
defaultValue: undefined,
onSubmit: undefined,
});
const openPreflightPromptModal = useCallback(
(props: {
placeholder: string;
defaultValue?: string;
onSubmit?: (value: string | null) => void;
}) => {
setIsPreflightPromptModalOpen(true);
setPreflightPromptModalProps({
placeholder: props.placeholder,
defaultValue: props.defaultValue,
onSubmit: props.onSubmit,
});
setIsPreflightPromptModalOpen(true);
},
[],
);
const containerRef = useRef<HTMLDivElement>(null);
const [isFullScreen, setIsFullScreen] = useState(false);
const goToFullScreen = useCallback(() => {
setIsFullScreen(true);
void containerRef.current?.requestFullscreen();
}, []);
const exitFullScreen = useCallback(() => {
setIsFullScreen(false);
void document.exitFullscreen();
}, []);
return (
<div className="hive-laboratory size-full" ref={containerRef}>
<Toaster richColors closeButton position="top-right" />
<Dialog open={isUpdateEndpointDialogOpen} onOpenChange={setIsUpdateEndpointDialogOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Update endpoint</DialogTitle>
<DialogDescription>Update the endpoint of your laboratory.</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
<form
id="update-endpoint-form"
onSubmit={e => {
e.preventDefault();
void updateEndpointForm.handleSubmit();
}}
>
<FieldGroup>
<updateEndpointForm.Field name="endpoint">
{field => {
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={e => field.handleChange(e.target.value)}
aria-invalid={isInvalid}
placeholder="Enter endpoint"
autoComplete="off"
/>
);
}}
</updateEndpointForm.Field>
</FieldGroup>
</form>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button type="submit" form="update-endpoint-form">
Update endpoint
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<PreflightPromptModal
open={isPreflightPromptModalOpen}
onOpenChange={setIsPreflightPromptModalOpen}
{...preflightPromptModalProps}
/>
<Dialog open={isAddCollectionDialogOpen} onOpenChange={setIsAddCollectionDialogOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Add collection</DialogTitle>
<DialogDescription>
Add a new collection of operations to your laboratory.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
<form
id="add-collection-form"
onSubmit={e => {
e.preventDefault();
void addCollectionForm.handleSubmit();
}}
>
<FieldGroup>
<addCollectionForm.Field name="name">
{field => {
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={field.name}>Name</FieldLabel>
<Input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={e => field.handleChange(e.target.value)}
aria-invalid={isInvalid}
placeholder="Enter name of the collection"
autoComplete="off"
/>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</Field>
);
}}
</addCollectionForm.Field>
</FieldGroup>
</form>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button type="submit" form="add-collection-form">
Add collection
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={isAddTestDialogOpen} onOpenChange={setIsAddTestDialogOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Add test</DialogTitle>
<DialogDescription>Add a new test to your laboratory.</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
<form
id="add-test-form"
onSubmit={e => {
e.preventDefault();
void addTestForm.handleSubmit();
}}
>
<FieldGroup>
<addTestForm.Field name="name">
{field => {
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={field.name}>Name</FieldLabel>
<Input
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={e => field.handleChange(e.target.value)}
aria-invalid={isInvalid}
placeholder="Enter name of the test"
autoComplete="off"
/>
{isInvalid && <FieldError errors={field.state.meta.errors} />}
</Field>
);
}}
</addTestForm.Field>
</FieldGroup>
</form>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button type="submit" form="add-test-form">
Add test
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<LaboratoryProvider
{...props}
{...testsApi}
{...settingsApi}
{...envApi}
{...preflightApi}
{...tabsApi}
{...endpointApi}
{...collectionsApi}
{...operationsApi}
{...historyApi}
openAddCollectionDialog={openAddCollectionDialog}
openUpdateEndpointDialog={openUpdateEndpointDialog}
openAddTestDialog={openAddTestDialog}
openPreflightPromptModal={openPreflightPromptModal}
goToFullScreen={goToFullScreen}
exitFullScreen={exitFullScreen}
isFullScreen={isFullScreen}
checkPermissions={checkPermissions}
>
<LaboratoryContent />
</LaboratoryProvider>
</div>
);
};

View file

@ -0,0 +1,740 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
BookmarkIcon,
CircleCheckIcon,
CircleXIcon,
ClockIcon,
FileTextIcon,
HistoryIcon,
MoreHorizontalIcon,
PlayIcon,
SquarePenIcon,
} from 'lucide-react';
import { compressToEncodedURIComponent } from 'lz-string';
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
import { toast } from 'sonner';
import { z } from 'zod';
import { Builder } from '@/laboratory/components/laboratory/builder';
import { useLaboratory } from '@/laboratory/components/laboratory/context';
import { Editor } from '@/laboratory/components/laboratory/editor';
import { Tabs } from '@/laboratory/components/tabs';
import { Badge } from '@/laboratory/components/ui/badge';
import { Button } from '@/laboratory/components/ui/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/laboratory/components/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
} from '@/laboratory/components/ui/dropdown-menu';
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/laboratory/components/ui/empty';
import { Field, FieldGroup, FieldLabel } from '@/laboratory/components/ui/field';
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from '@/laboratory/components/ui/resizable';
import { ScrollArea, ScrollBar } from '@/laboratory/components/ui/scroll-area';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/laboratory/components/ui/select';
import { Spinner } from '@/laboratory/components/ui/spinner';
import { Toggle } from '@/laboratory/components/ui/toggle';
import type {
LaboratoryHistory,
LaboratoryHistoryRequest,
LaboratoryHistorySubscription,
} from '@/laboratory/lib/history';
import type { LaboratoryOperation } from '@/laboratory/lib/operations';
import { cn } from '@/laboratory/lib/utils';
import { DropdownMenuTrigger } from '@radix-ui/react-dropdown-menu';
import { useForm } from '@tanstack/react-form';
const Variables = (props: { operation?: LaboratoryOperation | null; isReadOnly?: boolean }) => {
const { activeOperation, updateActiveOperation } = useLaboratory();
const operation = useMemo(() => {
return props.operation ?? activeOperation ?? null;
}, [props.operation, activeOperation]);
return (
<Editor
uri={monaco.Uri.file('variables.json')}
value={operation?.variables ?? ''}
onChange={value => {
updateActiveOperation({
variables: value ?? '',
});
}}
options={{
readOnly: props.isReadOnly,
}}
/>
);
};
const Headers = (props: { operation?: LaboratoryOperation | null; isReadOnly?: boolean }) => {
const { activeOperation, updateActiveOperation } = useLaboratory();
const operation = useMemo(() => {
return props.operation ?? activeOperation ?? null;
}, [props.operation, activeOperation]);
return (
<Editor
uri={monaco.Uri.file('headers.json')}
value={operation?.headers ?? ''}
onChange={value => {
updateActiveOperation({
headers: value ?? '',
});
}}
options={{
readOnly: props.isReadOnly,
}}
/>
);
};
const Extensions = (props: { operation?: LaboratoryOperation | null; isReadOnly?: boolean }) => {
const { activeOperation, updateActiveOperation } = useLaboratory();
const operation = useMemo(() => {
return props.operation ?? activeOperation ?? null;
}, [props.operation, activeOperation]);
return (
<Editor
uri={monaco.Uri.file('extensions.json')}
value={operation?.extensions ?? ''}
onChange={value => {
updateActiveOperation({
extensions: value ?? '',
});
}}
options={{
readOnly: props.isReadOnly,
}}
/>
);
};
export const ResponseBody = ({ historyItem }: { historyItem?: LaboratoryHistory | null }) => {
return (
<Editor
value={JSON.stringify(
JSON.parse((historyItem as LaboratoryHistoryRequest)?.response ?? '{}'),
null,
2,
)}
defaultLanguage="json"
theme="hive-laboratory"
options={{
readOnly: true,
}}
/>
);
};
export const ResponseHeaders = ({ historyItem }: { historyItem?: LaboratoryHistory | null }) => {
return (
<Editor
value={JSON.stringify(
JSON.parse((historyItem as LaboratoryHistoryRequest)?.headers ?? '{}'),
null,
2,
)}
defaultLanguage="json"
theme="hive-laboratory"
/>
);
};
export const ResponsePreflight = ({ historyItem }: { historyItem?: LaboratoryHistory | null }) => {
return (
<ScrollArea className="h-full">
<div className="flex flex-col gap-1.5 p-3">
{historyItem?.preflightLogs?.map((log, i) => (
<div className="gap-2 font-mono" key={i}>
<span className="text-muted-foreground text-xs">{log.createdAt}</span>{' '}
<span
className={cn('text-xs font-medium', {
'text-green-400': log.level === 'log',
'text-yellow-400': log.level === 'warn',
'text-red-400': log.level === 'error',
})}
>
{log.level.toUpperCase()}
</span>{' '}
<span className="text-xs">{log.message.join(' ')}</span>
</div>
))}
</div>
<ScrollBar />
</ScrollArea>
);
};
export const ResponseSubscription = ({
historyItem,
}: {
historyItem?: LaboratoryHistorySubscription | null;
}) => {
const { isActiveOperationLoading } = useLaboratory();
return (
<div className="flex h-full flex-col">
<div className="border-border flex h-12 border-b p-3 text-base font-medium">
Subscription
<div className="ml-auto flex items-center gap-2">
{isActiveOperationLoading ? (
<Badge variant="default" className="bg-green-400/10 text-green-500">
Listening
</Badge>
) : (
<Badge variant="default" className="bg-red-400/10 text-red-500">
Not listening
</Badge>
)}
</div>
</div>
<div className="flex-1 overflow-hidden">
<ScrollArea className="h-full">
<div className="flex flex-col">
{historyItem?.responses
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.map((response, i) => {
const value = [
`// ${response.createdAt}`,
'',
JSON.stringify(JSON.parse(response.data), null, 2),
].join('\n');
const height = 20.5 * value.split('\n').length;
return (
<div className="border-border border-b" style={{ height: `${height}px` }} key={i}>
<Editor
key={response.createdAt}
value={value}
defaultLanguage="json"
theme="hive-laboratory"
options={{
readOnly: true,
scrollBeyondLastLine: false,
scrollbar: {
vertical: 'hidden',
handleMouseWheel: false,
alwaysConsumeMouseWheel: false,
},
}}
/>
</div>
);
})}
</div>
<ScrollBar />
</ScrollArea>
</div>
</div>
);
};
export const Response = ({ historyItem }: { historyItem?: LaboratoryHistoryRequest | null }) => {
const isError = useMemo(() => {
if (!historyItem) {
return false;
}
return (
historyItem.status < 200 ||
historyItem.status >= 300 ||
('response' in historyItem && JSON.parse(historyItem.response).errors)
);
}, [historyItem]);
return (
<Tabs
suffix={
historyItem ? (
<div className="ml-auto flex items-center gap-2 pr-3">
<Badge
className={cn('bg-green-400/10 text-green-500', {
'bg-red-400/10 text-red-500': isError,
})}
>
{!isError ? (
<CircleCheckIcon className="size-3" />
) : (
<CircleXIcon className="size-3" />
)}
<span>{(historyItem as LaboratoryHistoryRequest).status}</span>
</Badge>
<Badge variant="outline" className="bg-card">
<ClockIcon className="size-3" />
<span>
{Math.round((historyItem as LaboratoryHistoryRequest).duration)}
ms
</span>
</Badge>
<Badge variant="outline" className="bg-card">
<FileTextIcon className="size-3" />
<span>
{Math.round((historyItem as LaboratoryHistoryRequest).size / 1024)}
KB
</span>
</Badge>
</div>
) : null
}
>
<Tabs.Item label="Response">
<ResponseBody historyItem={historyItem} />
</Tabs.Item>
<Tabs.Item label="Headers">
<ResponseHeaders historyItem={historyItem} />
</Tabs.Item>
{historyItem?.preflightLogs && historyItem?.preflightLogs.length > 0 ? (
<Tabs.Item label="Preflight">
<ResponsePreflight historyItem={historyItem} />
</Tabs.Item>
) : null}
</Tabs>
);
};
const saveToCollectionFormSchema = z.object({
collectionId: z.string().min(1, 'Collection is required'),
});
export const Query = (props: {
onAfterOperationRun?: (historyItem: LaboratoryHistory | null) => void;
operation?: LaboratoryOperation | null;
isReadOnly?: boolean;
}) => {
const {
endpoint,
runActiveOperation,
activeOperation,
isActiveOperationLoading,
updateActiveOperation,
collections,
addOperationToCollection,
addHistory,
stopActiveOperation,
addResponseToHistory,
isActiveOperationSubscription,
runPreflight,
addTab,
setActiveTab,
addOperation,
checkPermissions,
} = useLaboratory();
const operation = useMemo(() => {
return props.operation ?? activeOperation ?? null;
}, [props.operation, activeOperation]);
const handleRunOperation = useCallback(async () => {
if (!operation || !endpoint) {
return;
}
const result = await runPreflight?.();
if (isActiveOperationSubscription) {
const newItemHistory = addHistory({
responses: [],
operation,
preflightLogs: result?.logs ?? [],
createdAt: new Date().toISOString(),
} as Omit<LaboratoryHistorySubscription, 'id'>);
void runActiveOperation(endpoint, {
env: result?.env,
onResponse: data => {
addResponseToHistory(newItemHistory.id, data);
},
});
props.onAfterOperationRun?.(newItemHistory);
} else {
const startTime = performance.now();
const response = await runActiveOperation(endpoint, {
env: result?.env,
});
if (!response) {
return;
}
const status = response.status;
const duration = performance.now() - startTime;
const responseText = await response.text();
const size = responseText.length;
const newItemHistory = addHistory({
status,
duration,
size,
headers: JSON.stringify(Object.fromEntries(response.headers.entries()), null, 2),
operation,
preflightLogs: result?.logs ?? [],
response: responseText,
createdAt: new Date().toISOString(),
} as Omit<LaboratoryHistoryRequest, 'id'>);
props.onAfterOperationRun?.(newItemHistory);
}
}, [
operation,
endpoint,
isActiveOperationSubscription,
addHistory,
runActiveOperation,
props,
addResponseToHistory,
runPreflight,
]);
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
e.stopPropagation();
void handleRunOperation();
}
};
document.addEventListener('keydown', down, { capture: true });
return () => document.removeEventListener('keydown', down, { capture: true });
}, [handleRunOperation]);
const [isSaveToCollectionDialogOpen, setIsSaveToCollectionDialogOpen] = useState(false);
const saveToCollectionForm = useForm({
defaultValues: {
collectionId: '',
},
validators: {
onSubmit: saveToCollectionFormSchema,
},
onSubmit: ({ value }) => {
if (!operation) {
return;
}
addOperationToCollection(value.collectionId, {
id: operation.id ?? '',
name: operation.name ?? '',
query: operation.query ?? '',
variables: operation.variables ?? '',
headers: operation.headers ?? '',
extensions: operation.extensions ?? '',
description: '',
});
setIsSaveToCollectionDialogOpen(false);
},
});
const openSaveToCollectionDialog = useCallback(() => {
saveToCollectionForm.reset({
collectionId: collections[0]?.id ?? '',
});
setIsSaveToCollectionDialogOpen(true);
}, [saveToCollectionForm, collections]);
const isActiveOperationSavedToCollection = useMemo(() => {
return collections.some(c => c.operations.some(o => o.id === operation?.id));
}, [operation?.id, collections]);
const share = useCallback(
(options: { variables?: boolean; headers?: boolean; extensions?: boolean }) => {
const value = compressToEncodedURIComponent(
JSON.stringify({
n: operation?.name,
q: operation?.query,
v: options.variables ? operation?.variables : undefined,
h: options.headers ? operation?.headers : undefined,
e: options.extensions ? operation?.extensions : undefined,
}),
);
void navigator.clipboard.writeText(
`${window.location.origin}${window.location.pathname}?share=${value}`,
);
toast.success('Operation copied to clipboard');
},
[operation],
);
return (
<div className="grid size-full grid-rows-[auto_1fr] pb-0">
<Dialog open={isSaveToCollectionDialogOpen} onOpenChange={setIsSaveToCollectionDialogOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Add collection</DialogTitle>
<DialogDescription>
Add a new collection of operations to your laboratory.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
<form
id="save-to-collection"
onSubmit={e => {
e.preventDefault();
void saveToCollectionForm.handleSubmit();
}}
>
<FieldGroup>
<saveToCollectionForm.Field name="collectionId">
{field => {
const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid;
return (
<Field data-invalid={isInvalid}>
<FieldLabel htmlFor={field.name}>Collection</FieldLabel>
<Select
name={field.name}
value={field.state.value}
onValueChange={field.handleChange}
>
<SelectTrigger id={field.name} aria-invalid={isInvalid}>
<SelectValue placeholder="Select collection" />
</SelectTrigger>
<SelectContent>
{collections.map(c => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
);
}}
</saveToCollectionForm.Field>
</FieldGroup>
</form>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button type="submit" form="save-to-collection">
Save to collection
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<div className="border-border flex w-full items-center gap-2 border-b p-3">
<span className="text-base font-medium">Operation</span>
{checkPermissions?.('collectionsOperations:create') && (
<Toggle
aria-label="Save operation"
size="sm"
variant="default"
pressed={isActiveOperationSavedToCollection}
disabled={isActiveOperationSavedToCollection}
className="data-[state=on]:*:[svg]:fill-yellow-500 data-[state=on]:*:[svg]:stroke-yellow-500 h-6 data-[state=on]:bg-transparent"
onClick={openSaveToCollectionDialog}
>
<BookmarkIcon className="size-4" />
{isActiveOperationSavedToCollection ? 'Saved' : 'Save'}
</Toggle>
)}
<div className="ml-auto flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger>
<Button variant="outline" size="sm" className="h-6 rounded-sm">
Share
<MoreHorizontalIcon className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => share({ variables: true })}>
Share with variables
</DropdownMenuItem>
<DropdownMenuItem onClick={() => share({ variables: true, extensions: true })}>
Share with variables and extensions
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => share({ variables: true, headers: true, extensions: true })}
>
Share with variables, extensions, headers
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{!props.isReadOnly ? (
<Button
variant="default"
size="sm"
className="h-6 rounded-sm"
onClick={() => {
if (isActiveOperationLoading) {
stopActiveOperation?.();
} else {
void handleRunOperation();
}
}}
disabled={!operation || !endpoint}
>
{isActiveOperationLoading ? (
<>
<Spinner className="size-4" />
<span>Stop</span>
</>
) : (
<>
<PlayIcon className="size-4" />
<span>Run</span>
</>
)}
</Button>
) : (
<Button
variant="default"
size="sm"
className="h-6 rounded-sm"
onClick={() => {
if (!operation) {
return;
}
setActiveTab(
addTab({
type: 'operation',
data: addOperation(operation),
}),
);
}}
>
<SquarePenIcon className="size-4" />
Edit
</Button>
)}
</div>
</div>
<div className="size-full">
<Editor
uri={monaco.Uri.file('operation.graphql')}
variablesUri={monaco.Uri.file('variables.json')}
value={operation?.query ?? ''}
onChange={value => {
updateActiveOperation({
query: value ?? '',
});
}}
language="graphql"
theme="hive-laboratory"
options={{
readOnly: props.isReadOnly,
}}
/>
</div>
</div>
);
};
export const Operation = (props: {
operation?: LaboratoryOperation;
historyItem?: LaboratoryHistory;
}) => {
const { activeOperation, history } = useLaboratory();
const operation = useMemo(() => {
return props.operation ?? activeOperation ?? null;
}, [props.operation, activeOperation]);
const historyItem = useMemo(() => {
return (
props.historyItem ??
history
.filter(h => h.operation.id === operation?.id)
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())[0] ??
null
);
}, [history, props.historyItem, operation?.id]);
const isReadOnly = useMemo(() => {
return !!props.historyItem;
}, [props.historyItem]);
return (
<div className="bg-card size-full">
<ResizablePanelGroup direction="horizontal" className="size-full">
<ResizablePanel defaultSize={25}>
<Builder operation={operation} isReadOnly={isReadOnly} />
</ResizablePanel>
<ResizableHandle />
<ResizablePanel minSize={10} defaultSize={40}>
<ResizablePanelGroup direction="vertical">
<ResizablePanel defaultSize={70}>
<Query operation={operation} isReadOnly={isReadOnly} />
</ResizablePanel>
<ResizableHandle />
<ResizablePanel minSize={10} defaultSize={30} className="!overflow-visible">
<Tabs>
<Tabs.Item label="Variables">
<Variables operation={operation} isReadOnly={isReadOnly} />
</Tabs.Item>
<Tabs.Item label="Headers">
<Headers operation={operation} isReadOnly={isReadOnly} />
</Tabs.Item>
<Tabs.Item label="Extensions">
<Extensions operation={operation} isReadOnly={isReadOnly} />
</Tabs.Item>
</Tabs>
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel minSize={10} defaultSize={35}>
{historyItem ? (
<>
{'responses' in historyItem ? (
<ResponseSubscription historyItem={historyItem} />
) : (
<Response historyItem={historyItem} />
)}
</>
) : (
<Empty className="size-full">
<EmptyHeader>
<EmptyMedia variant="icon">
<HistoryIcon className="text-muted-foreground size-6" />
</EmptyMedia>
<EmptyTitle>No history yet</EmptyTitle>
<EmptyDescription>
No response available yet. Run your operation to see the response here.
</EmptyDescription>
</EmptyHeader>
</Empty>
)}
</ResizablePanel>
</ResizablePanelGroup>
</div>
);
};

View file

@ -0,0 +1,284 @@
import { useCallback } from 'react';
import { HistoryIcon, PlayIcon } from 'lucide-react';
import { useLaboratory } from '@/laboratory/components/laboratory/context';
import { Editor } from '@/laboratory/components/laboratory/editor';
import { Button } from '@/laboratory/components/ui/button';
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@/laboratory/components/ui/empty';
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from '@/laboratory/components/ui/resizable';
import { ScrollArea, ScrollBar } from '@/laboratory/components/ui/scroll-area';
import { runIsolatedLabScript } from '@/laboratory/lib/preflight';
import { cn } from '@/laboratory/lib/utils';
export const Preflight = () => {
const {
preflight,
setLastTestResult,
setPreflight,
env,
setEnv,
openPreflightPromptModal,
checkPermissions,
} = useLaboratory();
const run = useCallback(async () => {
if (!preflight?.script) {
return;
}
const result = await runIsolatedLabScript(
preflight?.script ?? '',
env ?? { variables: {} },
(placeholder, defaultValue) => {
return new Promise(resolve => {
openPreflightPromptModal?.({
placeholder,
defaultValue,
onSubmit: value => {
resolve(value);
},
});
});
},
);
setEnv(result?.env ?? { variables: {} });
setLastTestResult(result);
}, [env, setEnv, preflight, setLastTestResult, openPreflightPromptModal]);
return (
<ResizablePanelGroup direction="horizontal" className="size-full">
<ResizablePanel defaultSize={50} className="bg-card">
<div className="grid size-full grid-rows-[auto_1fr] pb-0">
<div className="border-border flex w-full items-center gap-2 border-b p-3">
<span className="text-base font-medium">Preflight</span>
<div className="ml-auto flex items-center gap-2">
<Button variant="default" size="sm" className="h-6 rounded-sm" onClick={run}>
<PlayIcon className="size-4" />
<span>Test</span>
</Button>
</div>
</div>
<div className="size-full">
<Editor
value={preflight?.script ?? ''}
onChange={value => {
setPreflight({
...(preflight ?? { script: '' }),
script: value ?? '',
});
}}
language="typescript"
extraLibs={[
`
interface Lab {
request: (endpoint: string, query: string, options?: { variables?: Record<string, unknown>; extensions?: Record<string, unknown>; headers?: Record<string, string> }) => Promise<Response>;
environment: {
set: (key: string, value: string) => void;
get: (key: string) => string;
delete: (key: string) => void;
};
prompt: (placeholder: string, defaultValue: string) => Promise<string | null>;
CryptoJS: typeof CryptoJS;
}
declare namespace CryptoJS {
namespace lib {
interface WordArray {
words: number[];
sigBytes: number;
toString(encoder?: Encoder): string;
concat(wordArray: WordArray): WordArray;
clone(): WordArray;
}
interface CipherParams {
ciphertext: WordArray;
key: WordArray;
iv: WordArray;
salt: WordArray;
toString(formatter?: Format): string;
}
}
namespace enc {
interface Encoder {
stringify(wordArray: lib.WordArray): string;
parse(str: string): lib.WordArray;
}
const Hex: Encoder;
const Latin1: Encoder;
const Utf8: Encoder;
const Base64: Encoder;
}
namespace algo {
interface HasherStatic {
create(cfg?: object): Hasher;
}
interface Hasher {
update(messageUpdate: lib.WordArray | string): Hasher;
finalize(messageUpdate?: lib.WordArray | string): lib.WordArray;
}
const MD5: HasherStatic;
const SHA1: HasherStatic;
const SHA256: HasherStatic;
const SHA224: HasherStatic;
const SHA512: HasherStatic;
const SHA384: HasherStatic;
const SHA3: HasherStatic;
const RIPEMD160: HasherStatic;
interface CipherStatic {
createEncryptor(key: lib.WordArray, cfg?: CipherOption): Cipher;
createDecryptor(key: lib.WordArray, cfg?: CipherOption): Cipher;
}
interface Cipher {
process(dataUpdate: lib.WordArray | string): lib.WordArray;
finalize(dataUpdate?: lib.WordArray | string): lib.WordArray;
}
interface CipherHelper {
encrypt(message: lib.WordArray | string, key: lib.WordArray | string, cfg?: CipherOption): lib.CipherParams;
decrypt(ciphertext: lib.CipherParams | string, key: lib.WordArray | string, cfg?: CipherOption): lib.WordArray;
}
const AES: CipherStatic;
const DES: CipherStatic;
const TripleDES: CipherStatic;
const RC4: CipherStatic;
}
namespace mode {
interface BlockCipherMode {
createEncryptor(cipher: algo.Cipher, iv: number[]): Mode;
createDecryptor(cipher: algo.Cipher, iv: number[]): Mode;
}
const CBC: BlockCipherMode;
const CFB: BlockCipherMode;
const CTR: BlockCipherMode;
const OFB: BlockCipherMode;
const ECB: BlockCipherMode;
}
namespace pad {
interface Padding {
pad(data: lib.WordArray, blockSize: number): void;
unpad(data: lib.WordArray): void;
}
const Pkcs7: Padding;
const AnsiX923: Padding;
const Iso10126: Padding;
const Iso97971: Padding;
const ZeroPadding: Padding;
const NoPadding: Padding;
}
namespace format {
interface Format {
stringify(cipherParams: lib.CipherParams): string;
parse(str: string): lib.CipherParams;
}
const OpenSSL: Format;
const Hex: Format;
}
interface CipherOption {
iv?: lib.WordArray;
mode?: mode.BlockCipherMode;
padding?: pad.Padding;
format?: format.Format;
[key: string]: any;
}
interface Mode {
processBlock(words: number[], offset: number): void;
}
type HasherHelper = (message: lib.WordArray | string, cfg?: object) => lib.WordArray;
type HmacHasherHelper = (message: lib.WordArray | string, key: lib.WordArray | string) => lib.WordArray;
type CipherHelper = {
encrypt(message: lib.WordArray | string, key: lib.WordArray | string, cfg?: CipherOption): lib.CipherParams;
decrypt(ciphertext: lib.CipherParams | string, key: lib.WordArray | string, cfg?: CipherOption): lib.WordArray;
};
const MD5: HasherHelper;
const SHA1: HasherHelper;
const SHA256: HasherHelper;
const SHA224: HasherHelper;
const SHA512: HasherHelper;
const SHA384: HasherHelper;
const SHA3: HasherHelper;
const RIPEMD160: HasherHelper;
const HmacMD5: HmacHasherHelper;
const HmacSHA1: HmacHasherHelper;
const HmacSHA256: HmacHasherHelper;
const HmacSHA224: HmacHasherHelper;
const HmacSHA512: HmacHasherHelper;
const HmacSHA384: HmacHasherHelper;
const HmacSHA3: HmacHasherHelper;
const HmacRIPEMD160: HmacHasherHelper;
const AES: CipherHelper;
const DES: CipherHelper;
const TripleDES: CipherHelper;
const RC4: CipherHelper;
const RC4Drop: CipherHelper;
const Rabbit: CipherHelper;
const RabbitLegacy: CipherHelper;
function PBKDF2(password: lib.WordArray | string, salt: lib.WordArray | string, cfg?: { keySize?: number; hasher?: algo.HasherStatic; iterations?: number }): lib.WordArray;
function EvpKDF(password: lib.WordArray | string, salt: lib.WordArray | string, cfg?: { keySize: number; hasher?: algo.HasherStatic; iterations: number }): lib.WordArray;
}
declare var lab: Lab;
declare var CryptoJS: typeof CryptoJS;
`,
]}
options={{
readOnly: !checkPermissions?.('preflight:update'),
}}
/>
</div>
</div>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel minSize={10} defaultSize={50} className="bg-card">
{preflight?.lastTestResult?.logs && preflight?.lastTestResult?.logs.length > 0 ? (
<div className="grid size-full grid-rows-[auto_1fr] pb-0">
<div className="border-border flex h-12 w-full items-center gap-2 border-b p-3">
<span className="text-base font-medium">Logs</span>
<div className="ml-auto flex items-center gap-2" />
</div>
<ScrollArea className="h-full">
<div className="flex flex-col gap-1.5 p-3">
{preflight?.lastTestResult?.logs.map((log, i) => (
<div className="gap-2 font-mono" key={i}>
<span className="text-muted-foreground text-xs">{log.createdAt}</span>{' '}
<span
className={cn('text-xs font-medium', {
'text-green-400': log.level === 'log',
'text-yellow-400': log.level === 'warn',
'text-red-400': log.level === 'error',
})}
>
{log.level.toUpperCase()}
</span>{' '}
<span className="text-xs">{log.message.join(' ')}</span>
</div>
))}
</div>
<ScrollBar />
</ScrollArea>
</div>
) : (
<Empty className="size-full">
<EmptyHeader>
<EmptyMedia variant="icon">
<HistoryIcon className="text-muted-foreground size-6" />
</EmptyMedia>
<EmptyTitle>No logs yet</EmptyTitle>
<EmptyDescription>
No logs available yet. Run your preflight to see the logs here.
</EmptyDescription>
</EmptyHeader>
</Empty>
)}
</ResizablePanel>
</ResizablePanelGroup>
);
};

View file

@ -0,0 +1,7 @@
export const Settings = () => {
return (
<div className="size-full p-3">
<div className="mx-auto max-w-2xl" />
</div>
);
};

View file

@ -0,0 +1,460 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { capitalize } from 'lodash';
import {
CirclePlus,
FileIcon,
FlaskConicalIcon,
GlobeIcon,
HistoryIcon,
LockIcon,
MaximizeIcon,
MinimizeIcon,
ScrollTextIcon,
SettingsIcon,
XIcon,
} from 'lucide-react';
import { GraphQLIcon } from '@/laboratory/components/icons';
import { useLaboratory } from '@/laboratory/components/laboratory/context';
import { Button } from '@/laboratory/components/ui/button';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from '@/laboratory/components/ui/context-menu';
import { ScrollArea, ScrollBar } from '@/laboratory/components/ui/scroll-area';
import * as Sortable from '@/laboratory/components/ui/sortable';
import { Spinner } from '@/laboratory/components/ui/spinner';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/laboratory/components/ui/tooltip';
import { getOperationName, getOperationType } from '@/laboratory/lib/operations.utils';
import type {
LaboratoryTab,
LaboratoryTabOperation,
LaboratoryTabTest,
} from '@/laboratory/lib/tabs';
import { cn } from '@/laboratory/lib/utils';
export const Tab = (props: {
item: LaboratoryTab;
activeTab: LaboratoryTab | null;
setActiveTab: (tab: LaboratoryTab) => void;
isOperationLoading: (id: string) => boolean;
handleDeleteTab: (id: string) => void;
handleDeleteAllTabs: () => void;
handleDeleteOtherTabs: (excludeTabId: string) => void;
isOverlay?: boolean;
}) => {
const { history, operations, tests } = useLaboratory();
const bypassMouseDownRef = useRef(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
function handleMouseUp() {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
bypassMouseDownRef.current = false;
}
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mouseup', handleMouseUp);
};
}, []);
const isActive = useMemo(() => {
return props.activeTab?.id === props.item.id;
}, [props.activeTab, props.item]);
const historyItem = useMemo(() => {
if (props.item.type !== 'history') {
return null;
}
return history.find(h => props.item.type === 'history' && h.id === props.item.data.id);
}, [history, props.item]);
const operation = useMemo(() => {
if (props.item.type !== 'operation') {
return null;
}
return operations.find(o => o.id === (props.item.data as LaboratoryTabOperation).id);
}, [props.item, operations]);
const test = useMemo(() => {
if (props.item.type !== 'test') {
return null;
}
return tests.find(t => t.id === (props.item.data as LaboratoryTabTest).id);
}, [props.item, tests]);
const isError = useMemo(() => {
if (!historyItem) {
return false;
}
return (
('status' in historyItem && historyItem.status < 200) ||
('status' in historyItem && historyItem.status >= 300) ||
('response' in historyItem && JSON.parse(historyItem.response).errors)
);
}, [historyItem]);
const closeTab = useCallback(() => {
props.handleDeleteTab(props.item.id);
}, [props]);
const closeAllTabs = useCallback(() => {
props.handleDeleteAllTabs();
}, [props]);
const closeOtherTabs = useCallback(() => {
props.handleDeleteOtherTabs(props.item.id);
}, [props]);
const tabName = useMemo(() => {
if (props.item.type === 'operation') {
const name = operation?.name || getOperationName(operation?.query || '') || 'Untitled';
if (name === 'Untitled') {
const type = capitalize(getOperationType(operation?.query || '') || 'query');
return name + type;
}
return name;
}
if (props.item.type === 'history') {
const name =
historyItem?.operation.name ||
getOperationName(historyItem?.operation.query || '') ||
'Untitled';
if (name === 'Untitled') {
const type = capitalize(getOperationType(historyItem?.operation.query || '') || 'query');
return name + type;
}
return name;
}
if (props.item.type === 'preflight') {
return 'Preflight';
}
if (props.item.type === 'env') {
return 'Environment Variables';
}
if (props.item.type === 'settings') {
return 'Settings';
}
if (props.item.type === 'test') {
return test?.name || 'Untitled';
}
return 'Untitled';
}, [props.item, historyItem, operation, test]);
const tabIcon = useMemo(() => {
if (props.item.type === 'operation') {
return <GraphQLIcon className="size-4 text-pink-500" />;
}
if (props.item.type === 'preflight') {
return <ScrollTextIcon className="size-4 text-teal-400" />;
}
if (props.item.type === 'env') {
return <GlobeIcon className="size-4 text-blue-400" />;
}
if (props.item.type === 'history') {
return (
<HistoryIcon
className={cn('size-4 text-indigo-400', {
'text-green-500': !isError,
'text-red-500': isError,
})}
/>
);
}
if (props.item.type === 'settings') {
return <SettingsIcon className="size-4 text-gray-400" />;
}
if (props.item.type === 'test') {
return <FlaskConicalIcon className="size-4 text-lime-400" />;
}
return <FileIcon className="text-muted-foreground size-4" />;
}, [props.item, isError]);
return (
<ContextMenu>
<ContextMenuTrigger asChild>
<Sortable.Item
value={props.item.id}
asHandle
className={cn(
'data-dragging:opacity-0 flex h-12 w-max items-stretch',
props.isOverlay && 'bg-background',
props.isOverlay && !isActive && 'h-12',
)}
>
<div
onMouseDown={e => {
if (bypassMouseDownRef.current) {
return;
}
e.preventDefault();
const event = {
...e,
};
timeoutRef.current = setTimeout(() => {
bypassMouseDownRef.current = true;
event.currentTarget.dispatchEvent(
new MouseEvent('mousedown', {
...(event as unknown as MouseEventInit),
}),
);
}, 200);
}}
>
<div
className={cn(
'text-muted-foreground hover:text-foreground group relative flex h-full cursor-pointer items-center gap-2 border-t-2 border-transparent px-3 pb-1 transition-all',
props.activeTab?.id === props.item.id && 'border-primary bg-card text-foreground',
)}
onClick={() => {
props.setActiveTab(props.item);
}}
onMouseUp={() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
bypassMouseDownRef.current = false;
}}
>
{tabIcon}
{tabName}
{props.isOperationLoading(props.item.id) && <Spinner className="size-3" />}
{props.item.readOnly && <LockIcon className="size-3 text-gray-400" />}
<XIcon
className="text-muted-foreground size-3"
onMouseDown={e => {
e.stopPropagation();
}}
onClick={e => {
e.stopPropagation();
props.handleDeleteTab(props.item.id);
}}
/>
</div>
</div>
<div className="bg-border mb-px w-px" />
</Sortable.Item>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={closeTab}>Close</ContextMenuItem>
<ContextMenuItem onClick={closeOtherTabs}>Close other</ContextMenuItem>
<ContextMenuItem onClick={closeAllTabs}>Close all</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
};
export const Tabs = ({ className }: { className?: string }) => {
const {
tabs,
setTabs,
activeTab,
addTab,
deleteTab,
operations,
setActiveTab,
addOperation,
setOperations,
deleteOperation,
isOperationLoading,
goToFullScreen,
exitFullScreen,
isFullScreen,
} = useLaboratory();
const handleAddOperation = useCallback(() => {
const newOperation = addOperation({
name: '',
query: '',
variables: '',
headers: '',
extensions: '',
});
const tab = addTab({
type: 'operation',
data: newOperation,
});
setActiveTab(tab);
}, [addOperation, addTab, setActiveTab]);
const handleDeleteTab = useCallback(
(tabId: string) => {
const tabIndex = tabs.findIndex(t => t.id === tabId);
if (tabIndex === -1) {
return;
}
const tab = tabs[tabIndex];
if (tab.type === 'operation') {
deleteOperation(tab.data.id);
}
deleteTab(tab.id);
if (tabIndex === 0) {
setActiveTab(tabs[1] ?? null);
} else if (tabIndex > 0) {
setActiveTab(tabs[tabIndex - 1] ?? null);
} else {
setActiveTab(tabs[0] ?? null);
}
},
[tabs, deleteTab, deleteOperation, setActiveTab],
);
const handleDeleteAllTabs = useCallback(() => {
setOperations([]);
setTabs([]);
}, [setOperations, setTabs]);
const handleDeleteOtherTabs = useCallback(
(excludeTabId: string) => {
const newActiveTab = tabs.find(t => t.id === excludeTabId);
if (newActiveTab) {
const tabsToDelete = tabs.filter(t => t.id !== excludeTabId);
const operationsToDelete = tabsToDelete
.filter(t => t.type === 'operation')
.map(t => t.data.id);
setOperations(operations.filter(o => !operationsToDelete.includes(o.id)));
setTabs([newActiveTab]);
setActiveTab(newActiveTab);
}
},
[tabs, setOperations, operations, setTabs, setActiveTab],
);
return (
<div
className={cn('relative z-10 grid size-full grid-cols-[1fr_auto] overflow-hidden', className)}
>
<div className="bg-border absolute bottom-0 left-0 -z-10 h-px w-full" />
<div className="overflow-hidden">
<ScrollArea className="size-full whitespace-nowrap">
<div className="flex items-stretch">
<Sortable.Root
value={tabs}
onValueChange={setTabs}
getItemValue={(item: LaboratoryTab) => item.id}
orientation="horizontal"
>
<Sortable.Content className="flex w-max items-stretch">
{tabs.map(item => {
return (
<>
<Tab
key={item.id}
item={item}
activeTab={activeTab}
setActiveTab={setActiveTab}
isOperationLoading={isOperationLoading}
handleDeleteTab={handleDeleteTab}
handleDeleteAllTabs={handleDeleteAllTabs}
handleDeleteOtherTabs={handleDeleteOtherTabs}
/>
</>
);
})}
</Sortable.Content>
<Sortable.Overlay>
{activeItem => {
const tab = tabs.find(t => t.id === activeItem.value);
if (!tab) {
return null;
}
return (
<Tab
item={tab}
activeTab={activeTab}
setActiveTab={setActiveTab}
isOperationLoading={isOperationLoading}
handleDeleteTab={handleDeleteTab}
isOverlay
handleDeleteAllTabs={handleDeleteAllTabs}
handleDeleteOtherTabs={handleDeleteOtherTabs}
/>
);
}}
</Sortable.Overlay>
</Sortable.Root>
<div className="group ml-2 flex h-12 items-center border-b-2 border-transparent">
<Button
variant="ghost"
size="sm"
className="text-primary hover:text-primary"
onClick={handleAddOperation}
>
<CirclePlus className="size-4" />
Add operation
</Button>
</div>
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</div>
<div className="group flex h-12 items-center border-l px-2">
{isFullScreen ? (
<Tooltip>
<TooltipTrigger>
<Button variant="ghost" size="sm" onClick={exitFullScreen}>
<MinimizeIcon className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Exit full screen</TooltipContent>
</Tooltip>
) : (
<Tooltip>
<TooltipTrigger>
<Button variant="ghost" size="sm" onClick={goToFullScreen}>
<MaximizeIcon className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">Go to full screen</TooltipContent>
</Tooltip>
)}
</div>
</div>
);
};

View file

@ -0,0 +1,67 @@
import { Children, Fragment, useEffect, useMemo, useState } from 'react';
import { cn } from '@/laboratory/lib/utils';
interface ItemProps {
label: string;
children: React.ReactNode;
}
const Item = (_props: ItemProps) => {
return null;
};
export interface TabsProps {
children: (React.ReactElement<ItemProps> | null)[];
suffix?: React.ReactNode;
}
export const Tabs = ({ children, suffix }: TabsProps) => {
const filteredChildren = useMemo(() => {
return children.filter(child => child !== null);
}, [children]);
const [activeTab, setActiveTab] = useState<string | null>(
filteredChildren[0].props.label ?? null,
);
useEffect(() => {
if (activeTab && !filteredChildren.some(child => child.props.label === activeTab)) {
setActiveTab(filteredChildren[0].props.label ?? null);
}
}, [activeTab, filteredChildren]);
const activeChild = useMemo(() => {
return filteredChildren.find(child => child.props.label === activeTab)?.props.children ?? null;
}, [filteredChildren, activeTab]);
return (
<div className="grid size-full grid-rows-[auto_1fr] pb-0">
<div className="bg-background relative z-10 flex h-12 w-full items-center overflow-hidden">
<div className="bg-border absolute bottom-0 left-0 -z-10 h-px w-full" />
<div className="flex h-full w-max items-stretch">
{Children.map(filteredChildren, child => (
<Fragment key={child?.props.label}>
<div
className={cn(
'text-muted-foreground hover:text-foreground group relative flex cursor-pointer items-center gap-2 border-t-2 border-transparent px-3 pb-1 font-medium transition-all',
{
'border-primary bg-card text-foreground-primary':
activeTab === child.props.label,
},
)}
onClick={() => setActiveTab(child.props.label)}
>
{child.props.label}
</div>
<div className="bg-border mb-px w-px" />
</Fragment>
))}
</div>
{suffix}
</div>
<div className="size-full">{activeChild}</div>
</div>
);
};
Tabs.Item = Item;

View file

@ -0,0 +1,131 @@
import { buttonVariants } from '@/laboratory/components/ui/button';
import { cn } from '@/laboratory/lib/utils';
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
function AlertDialog({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />;
}
function AlertDialogPortal({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />;
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
className,
)}
{...props}
/>
);
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed left-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
className,
)}
{...props}
/>
</AlertDialogPortal>
);
}
function AlertDialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="alert-dialog-header"
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
{...props}
/>
);
}
function AlertDialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="alert-dialog-footer"
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
{...props}
/>
);
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn('text-lg font-semibold', className)}
{...props}
/>
);
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return <AlertDialogPrimitive.Action className={cn(buttonVariants(), className)} {...props} />;
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: 'outline' }), className)}
{...props}
/>
);
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};

View file

@ -0,0 +1,37 @@
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/laboratory/lib/utils';
import { Slot } from '@radix-ui/react-slot';
const badgeVariants = cva(
'inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
secondary:
'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
destructive:
'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
},
},
defaultVariants: {
variant: 'default',
},
},
);
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<'span'> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'span';
return (
<Comp data-slot="badge" className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };

View file

@ -0,0 +1,77 @@
import { cva, type VariantProps } from 'class-variance-authority';
import { Separator } from '@/laboratory/components/ui/separator';
import { cn } from '@/laboratory/lib/utils';
import { Slot } from '@radix-ui/react-slot';
const buttonGroupVariants = cva(
"flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
{
variants: {
orientation: {
horizontal:
'[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none',
vertical:
'flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none',
},
},
defaultVariants: {
orientation: 'horizontal',
},
},
);
function ButtonGroup({
className,
orientation,
...props
}: React.ComponentProps<'div'> & VariantProps<typeof buttonGroupVariants>) {
return (
<div
role="group"
data-slot="button-group"
data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
);
}
function ButtonGroupText({
className,
asChild = false,
...props
}: React.ComponentProps<'div'> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : 'div';
return (
<Comp
className={cn(
"bg-muted flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-sm [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none",
className,
)}
{...props}
/>
);
}
function ButtonGroupSeparator({
className,
orientation = 'vertical',
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="button-group-separator"
orientation={orientation}
className={cn(
'bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto',
className,
)}
{...props}
/>
);
}
export { ButtonGroup, ButtonGroupSeparator, ButtonGroupText, buttonGroupVariants };

View file

@ -0,0 +1,56 @@
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/laboratory/lib/utils';
import { Slot } from '@radix-ui/react-slot';
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
'bg-destructive !text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'border bg-background shadow-sm hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
'icon-sm': 'size-8',
'icon-lg': 'size-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : 'button';
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };

View file

@ -0,0 +1,73 @@
import { cn } from '@/laboratory/lib/utils';
function Card({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card"
className={cn(
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-header"
className={cn(
'has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6 grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6',
className,
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-title"
className={cn('font-semibold leading-none', className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
}
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-action"
className={cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', className)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return <div data-slot="card-content" className={cn('px-6', className)} {...props} />;
}
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-footer"
className={cn('[.border-t]:pt-6 flex items-center px-6', className)}
{...props}
/>
);
}
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };

View file

@ -0,0 +1,25 @@
import { CheckIcon } from 'lucide-react';
import { cn } from '@/laboratory/lib/utils';
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
'border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive peer size-4 shrink-0 rounded-[4px] border shadow-sm outline-none transition-shadow focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export { Checkbox };

View file

@ -0,0 +1,19 @@
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
function Collapsible({ ...props }: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return <CollapsiblePrimitive.CollapsibleTrigger data-slot="collapsible-trigger" {...props} />;
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return <CollapsiblePrimitive.CollapsibleContent data-slot="collapsible-content" {...props} />;
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent };

View file

@ -0,0 +1,159 @@
'use client';
import { Command as CommandPrimitive } from 'cmdk';
import { SearchIcon } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/laboratory/components/ui/dialog';
import { cn } from '@/laboratory/lib/utils';
function Command({ className, ...props }: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
'bg-popover text-popover-foreground flex size-full flex-col overflow-hidden rounded-md',
className,
)}
{...props}
/>
);
}
function CommandDialog({
title = 'Command Palette',
description = 'Search for a command to run...',
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string;
description?: string;
className?: string;
showCloseButton?: boolean;
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn('overflow-hidden p-0', className)}
showCloseButton={showCloseButton}
>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:size-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:size-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div data-slot="command-input-wrapper" className="flex h-9 items-center gap-2 border-b px-3">
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
'placeholder:text-muted-foreground outline-hidden flex h-10 w-full rounded-md bg-transparent py-3 text-sm disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
</div>
);
}
function CommandList({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn('max-h-[300px] scroll-py-1 overflow-y-auto overflow-x-hidden', className)}
{...props}
/>
);
}
function CommandEmpty({ ...props }: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
);
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
'text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium',
className,
)}
{...props}
/>
);
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn('bg-border -mx-1 h-px', className)}
{...props}
/>
);
}
function CommandItem({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...props}
/>
);
}
function CommandShortcut({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot="command-shortcut"
className={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
{...props}
/>
);
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};

View file

@ -0,0 +1,220 @@
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
import { cn } from '@/laboratory/lib/utils';
import * as ContextMenuPrimitive from '@radix-ui/react-context-menu';
function ContextMenu({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />;
}
function ContextMenuTrigger({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return <ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />;
}
function ContextMenuGroup({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return <ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />;
}
function ContextMenuPortal({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return <ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />;
}
function ContextMenuSub({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />;
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return <ContextMenuPrimitive.RadioGroup data-slot="context-menu-radio-group" {...props} />;
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm data-[inset]:pl-8 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
);
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-[var(--radix-context-menu-content-transform-origin)] overflow-hidden rounded-md border p-1 shadow-lg',
className,
)}
{...props}
/>
);
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-[var(--radix-context-menu-content-available-height)] min-w-[8rem] origin-[var(--radix-context-menu-content-transform-origin)] overflow-y-auto overflow-x-hidden rounded-md border p-1 shadow-md',
className,
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
);
}
function ContextMenuItem({
className,
inset,
variant = 'default',
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
variant?: 'default' | 'destructive';
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm data-[disabled]:pointer-events-none data-[inset]:pl-8 data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...props}
/>
);
}
function ContextMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-8 pr-2 text-sm data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
);
}
function ContextMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-8 pr-2 text-sm data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
);
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn('text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', className)}
{...props}
/>
);
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn('bg-border -mx-1 my-1 h-px', className)}
{...props}
/>
);
}
function ContextMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot="context-menu-shortcut"
className={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
{...props}
/>
);
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
};

View file

@ -0,0 +1,125 @@
import { XIcon } from 'lucide-react';
import { cn } from '@/laboratory/lib/utils';
import * as DialogPrimitive from '@radix-ui/react-dialog';
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed left-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground rounded-xs focus:outline-hidden absolute right-4 top-4 opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="dialog-header"
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
{...props}
/>
);
}
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="dialog-footer"
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
{...props}
/>
);
}
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn('text-lg font-semibold leading-none', className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};

View file

@ -0,0 +1,224 @@
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
import { cn } from '@/laboratory/lib/utils';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-[var(--radix-dropdown-menu-content-transform-origin)] overflow-y-auto overflow-x-hidden rounded-md border p-1 shadow-md',
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
}
function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
}
function DropdownMenuItem({
className,
inset,
variant = 'default',
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: 'default' | 'destructive';
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm data-[disabled]:pointer-events-none data-[inset]:pl-8 data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-8 pr-2 text-sm data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-8 pr-2 text-sm data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn('px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', className)}
{...props}
/>
);
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn('bg-border -mx-1 my-1 h-px', className)}
{...props}
/>
);
}
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
{...props}
/>
);
}
function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm data-[inset]:pl-8 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-[var(--radix-dropdown-menu-content-transform-origin)] overflow-hidden rounded-md border p-1 shadow-lg',
className,
)}
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
};

View file

@ -0,0 +1,93 @@
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/laboratory/lib/utils';
function Empty({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="empty"
className={cn(
'flex min-w-0 flex-1 flex-col items-center justify-center gap-6 text-balance rounded-lg border-dashed p-6 text-center md:p-12',
className,
)}
{...props}
/>
);
}
function EmptyHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="empty-header"
className={cn('flex max-w-sm flex-col items-center gap-2 text-center', className)}
{...props}
/>
);
}
const emptyMediaVariants = cva(
'flex shrink-0 items-center justify-center mb-2 [&_svg]:pointer-events-none [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-transparent',
icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
},
},
defaultVariants: {
variant: 'default',
},
},
);
function EmptyMedia({
className,
variant = 'default',
...props
}: React.ComponentProps<'div'> & VariantProps<typeof emptyMediaVariants>) {
return (
<div
data-slot="empty-icon"
data-variant={variant}
className={cn(emptyMediaVariants({ variant, className }))}
{...props}
/>
);
}
function EmptyTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="empty-title"
className={cn('text-lg font-medium tracking-tight', className)}
{...props}
/>
);
}
function EmptyDescription({ className, ...props }: React.ComponentProps<'p'>) {
return (
<div
data-slot="empty-description"
className={cn(
'text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4',
className,
)}
{...props}
/>
);
}
function EmptyContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="empty-content"
className={cn(
'flex w-full min-w-0 max-w-sm flex-col items-center gap-4 text-balance text-sm',
className,
)}
{...props}
/>
);
}
export { Empty, EmptyHeader, EmptyTitle, EmptyDescription, EmptyContent, EmptyMedia };

View file

@ -0,0 +1,231 @@
import { useMemo } from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { Label } from '@/laboratory/components/ui/label';
import { Separator } from '@/laboratory/components/ui/separator';
import { cn } from '@/laboratory/lib/utils';
function FieldSet({ className, ...props }: React.ComponentProps<'fieldset'>) {
return (
<fieldset
data-slot="field-set"
className={cn(
'flex flex-col gap-6',
'has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3',
className,
)}
{...props}
/>
);
}
function FieldLegend({
className,
variant = 'legend',
...props
}: React.ComponentProps<'legend'> & { variant?: 'legend' | 'label' }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
'mb-3 font-medium',
'data-[variant=legend]:text-base',
'data-[variant=label]:text-sm',
className,
)}
{...props}
/>
);
}
function FieldGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="field-group"
className={cn(
'group/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4',
className,
)}
{...props}
/>
);
}
const fieldVariants = cva('group/field flex w-full gap-3 data-[invalid=true]:text-destructive', {
variants: {
orientation: {
vertical: ['flex-col [&>*]:w-full [&>.sr-only]:w-auto'],
horizontal: [
'flex-row items-center',
'[&>[data-slot=field-label]]:flex-auto',
'has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px',
],
responsive: [
'flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto',
'@md/field-group:[&>[data-slot=field-label]]:flex-auto',
'@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px',
],
},
},
defaultVariants: {
orientation: 'vertical',
},
});
function Field({
className,
orientation = 'vertical',
...props
}: React.ComponentProps<'div'> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
);
}
function FieldContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="field-content"
className={cn('group/field-content flex flex-1 flex-col gap-1.5 leading-snug', className)}
{...props}
/>
);
}
function FieldLabel({ className, ...props }: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
'group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50',
'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4',
'has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10',
className,
)}
{...props}
/>
);
}
function FieldTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="field-label"
className={cn(
'flex w-fit items-center gap-2 text-sm font-medium leading-snug group-data-[disabled=true]/field:opacity-50',
className,
)}
{...props}
/>
);
}
function FieldDescription({ className, ...props }: React.ComponentProps<'p'>) {
return (
<p
data-slot="field-description"
className={cn(
'text-muted-foreground text-sm font-normal leading-normal group-has-[[data-orientation=horizontal]]/field:text-balance',
'nth-last-2:-mt-1 last:mt-0 [[data-variant=legend]+&]:-mt-1.5',
'[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
className,
)}
{...props}
/>
);
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<'div'> & {
children?: React.ReactNode;
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
'relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2',
className,
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
);
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<'div'> & {
errors?: Array<{ message?: string } | undefined>;
}) {
const content = useMemo(() => {
if (children) {
return children;
}
if (!errors?.length) {
return null;
}
const uniqueErrors = [...new Map(errors.map(error => [error?.message, error])).values()];
if (uniqueErrors?.length === 1) {
return uniqueErrors[0]?.message;
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map((error, index) => error?.message && <li key={index}>{error.message}</li>)}
</ul>
);
}, [children, errors]);
if (!content) {
return null;
}
return (
<div
role="alert"
data-slot="field-error"
className={cn('text-destructive text-sm font-normal', className)}
{...props}
>
{content}
</div>
);
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
};

View file

@ -0,0 +1,19 @@
import { cn } from '@/laboratory/lib/utils';
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
return (
<input
type={type}
data-slot="input"
className={cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-sm outline-none transition-[color,box-shadow] file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
className,
)}
{...props}
/>
);
}
export { Input };

View file

@ -0,0 +1,17 @@
import { cn } from '@/laboratory/lib/utils';
import * as LabelPrimitive from '@radix-ui/react-label';
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
'flex select-none items-center gap-2 text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50 group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50',
className,
)}
{...props}
/>
);
}
export { Label };

View file

@ -0,0 +1,47 @@
import { GripVerticalIcon } from 'lucide-react';
import * as ResizablePrimitive from 'react-resizable-panels';
import { cn } from '@/laboratory/lib/utils';
function ResizablePanelGroup({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
return (
<ResizablePrimitive.PanelGroup
data-slot="resizable-panel-group"
className={cn('flex size-full data-[panel-group-direction=vertical]:flex-col', className)}
{...props}
/>
);
}
function ResizablePanel({ ...props }: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
}
function ResizableHandle({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean;
}) {
return (
<ResizablePrimitive.PanelResizeHandle
data-slot="resizable-handle"
className={cn(
'bg-border focus-visible:ring-ring focus-visible:outline-hidden relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90',
className,
)}
{...props}
>
{withHandle && (
<div className="bg-border rounded-xs z-10 flex h-4 w-3 items-center justify-center border">
<GripVerticalIcon className="size-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
);
}
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };

View file

@ -0,0 +1,52 @@
import { cn } from '@/laboratory/lib/utils';
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn('relative', className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] outline-none transition-[color,box-shadow] focus-visible:outline-1 focus-visible:ring-[3px]"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
);
}
function ScrollBar({
className,
orientation = 'vertical',
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
'flex touch-none select-none p-px transition-colors',
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent',
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent',
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
);
}
export { ScrollArea, ScrollBar };

View file

@ -0,0 +1,168 @@
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react';
import { cn } from '@/laboratory/lib/utils';
import * as SelectPrimitive from '@radix-ui/react-select';
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = 'default',
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: 'sm' | 'default';
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 whitespace-nowrap rounded-md border bg-transparent px-3 py-2 text-sm shadow-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = 'popper',
align = 'center',
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-[var(--radix-select-content-available-height)] min-w-[8rem] origin-[var(--radix-select-content-transform-origin)] overflow-y-auto overflow-x-hidden rounded-md border shadow-md',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className,
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1',
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn('text-muted-foreground px-2 py-1.5 text-xs', className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-2 pr-8 text-sm data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn('bg-border pointer-events-none -mx-1 my-1 h-px', className)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};

View file

@ -0,0 +1,26 @@
'use client';
import { cn } from '@/laboratory/lib/utils';
import * as SeparatorPrimitive from '@radix-ui/react-separator';
function Separator({
className,
orientation = 'horizontal',
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=vertical]:h-full data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px',
className,
)}
{...props}
/>
);
}
export { Separator };

View file

@ -0,0 +1,38 @@
import {
CircleCheckIcon,
InfoIcon,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from 'lucide-react';
import { useTheme } from 'next-themes';
import { Toaster as Sonner, type ToasterProps } from 'sonner';
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = 'system' } = useTheme();
return (
<Sonner
theme={theme as ToasterProps['theme']}
className="group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
'--normal-bg': 'var(--popover)',
'--normal-text': 'var(--popover-foreground)',
'--normal-border': 'var(--border)',
'--border-radius': 'var(--radius)',
} as React.CSSProperties
}
{...props}
/>
);
};
export { Toaster };

View file

@ -0,0 +1,542 @@
'use client';
import {
createContext,
useCallback,
useContext,
useId,
useLayoutEffect,
useMemo,
useState,
} from 'react';
import * as ReactDOM from 'react-dom';
import { useComposedRefs } from '@/laboratory/lib/compose-refs';
import { cn } from '@/laboratory/lib/utils';
import {
closestCenter,
closestCorners,
defaultDropAnimationSideEffects,
DndContext,
DragOverlay,
KeyboardSensor,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
type Announcements,
type DndContextProps,
type DragEndEvent,
type DraggableAttributes,
type DraggableSyntheticListeners,
type DragStartEvent,
type DropAnimation,
type ScreenReaderInstructions,
type UniqueIdentifier,
} from '@dnd-kit/core';
import {
restrictToHorizontalAxis,
restrictToParentElement,
restrictToVerticalAxis,
} from '@dnd-kit/modifiers';
import {
arrayMove,
horizontalListSortingStrategy,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
type SortableContextProps,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { Slot } from '@radix-ui/react-slot';
const orientationConfig = {
vertical: {
modifiers: [restrictToVerticalAxis, restrictToParentElement],
strategy: verticalListSortingStrategy,
collisionDetection: closestCenter,
},
horizontal: {
modifiers: [restrictToHorizontalAxis, restrictToParentElement],
strategy: horizontalListSortingStrategy,
collisionDetection: closestCenter,
},
mixed: {
modifiers: [restrictToParentElement],
strategy: undefined,
collisionDetection: closestCorners,
},
};
const ROOT_NAME = 'Sortable';
const CONTENT_NAME = 'SortableContent';
const ITEM_NAME = 'SortableItem';
const ITEM_HANDLE_NAME = 'SortableItemHandle';
const OVERLAY_NAME = 'SortableOverlay';
interface SortableRootContextValue<T> {
id: string;
items: UniqueIdentifier[];
modifiers: DndContextProps['modifiers'];
strategy: SortableContextProps['strategy'];
activeId: UniqueIdentifier | null;
setActiveId: (id: UniqueIdentifier | null) => void;
getItemValue: (item: T) => UniqueIdentifier;
flatCursor: boolean;
}
const SortableRootContext = createContext<SortableRootContextValue<unknown> | null>(null);
function useSortableContext(consumerName: string) {
const context = useContext(SortableRootContext);
if (!context) {
throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``);
}
return context;
}
interface GetItemValue<T> {
/**
* Callback that returns a unique identifier for each sortable item. Required for array of objects.
* @example getItemValue={(item) => item.id}
*/
getItemValue: (item: T) => UniqueIdentifier;
}
type SortableRootProps<T> = DndContextProps &
(T extends object ? GetItemValue<T> : Partial<GetItemValue<T>>) & {
value: T[];
onValueChange?: (items: T[]) => void;
onMove?: (event: DragEndEvent & { activeIndex: number; overIndex: number }) => void;
strategy?: SortableContextProps['strategy'];
orientation?: 'vertical' | 'horizontal' | 'mixed';
flatCursor?: boolean;
};
function SortableRoot<T>(props: SortableRootProps<T>) {
const {
value,
onValueChange,
collisionDetection,
modifiers,
strategy,
onMove,
orientation = 'vertical',
flatCursor = false,
getItemValue: getItemValueProp,
accessibility,
...sortableProps
} = props;
const id = useId();
const [activeId, setActiveId] = useState<UniqueIdentifier | null>(null);
const sensors = useSensors(
useSensor(MouseSensor),
useSensor(TouchSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const config = useMemo(() => orientationConfig[orientation], [orientation]);
const getItemValue = useCallback(
(item: T): UniqueIdentifier => {
if (typeof item === 'object' && !getItemValueProp) {
throw new Error('getItemValue is required when using array of objects');
}
return getItemValueProp ? getItemValueProp(item) : (item as UniqueIdentifier);
},
[getItemValueProp],
);
const items = useMemo(() => {
return value.map(item => getItemValue(item));
}, [value, getItemValue]);
const onDragStart = useCallback(
(event: DragStartEvent) => {
sortableProps.onDragStart?.(event);
if (event.activatorEvent.defaultPrevented) return;
setActiveId(event.active.id);
},
[sortableProps.onDragStart],
);
const onDragEnd = useCallback(
(event: DragEndEvent) => {
sortableProps.onDragEnd?.(event);
if (event.activatorEvent.defaultPrevented) return;
const { active, over } = event;
if (over && active.id !== over?.id) {
const activeIndex = value.findIndex(item => getItemValue(item) === active.id);
const overIndex = value.findIndex(item => getItemValue(item) === over.id);
if (onMove) {
onMove({ ...event, activeIndex, overIndex });
} else {
onValueChange?.(arrayMove(value, activeIndex, overIndex));
}
}
setActiveId(null);
},
[value, onValueChange, onMove, getItemValue, sortableProps.onDragEnd],
);
const onDragCancel = useCallback(
(event: DragEndEvent) => {
sortableProps.onDragCancel?.(event);
if (event.activatorEvent.defaultPrevented) return;
setActiveId(null);
},
[sortableProps.onDragCancel],
);
const announcements: Announcements = useMemo(
() => ({
onDragStart({ active }) {
const activeValue = active.id.toString();
return `Grabbed sortable item "${activeValue}". Current position is ${active.data.current?.sortable.index + 1} of ${value.length}. Use arrow keys to move, space to drop.`;
},
onDragOver({ active, over }) {
if (over) {
const overIndex = over.data.current?.sortable.index ?? 0;
const activeIndex = active.data.current?.sortable.index ?? 0;
const moveDirection = overIndex > activeIndex ? 'down' : 'up';
const activeValue = active.id.toString();
return `Sortable item "${activeValue}" moved ${moveDirection} to position ${overIndex + 1} of ${value.length}.`;
}
return 'Sortable item is no longer over a droppable area. Press escape to cancel.';
},
onDragEnd({ active, over }) {
const activeValue = active.id.toString();
if (over) {
const overIndex = over.data.current?.sortable.index ?? 0;
return `Sortable item "${activeValue}" dropped at position ${overIndex + 1} of ${value.length}.`;
}
return `Sortable item "${activeValue}" dropped. No changes were made.`;
},
onDragCancel({ active }) {
const activeIndex = active.data.current?.sortable.index ?? 0;
const activeValue = active.id.toString();
return `Sorting cancelled. Sortable item "${activeValue}" returned to position ${activeIndex + 1} of ${value.length}.`;
},
onDragMove({ active, over }) {
if (over) {
const overIndex = over.data.current?.sortable.index ?? 0;
const activeIndex = active.data.current?.sortable.index ?? 0;
const moveDirection = overIndex > activeIndex ? 'down' : 'up';
const activeValue = active.id.toString();
return `Sortable item "${activeValue}" is moving ${moveDirection} to position ${overIndex + 1} of ${value.length}.`;
}
return 'Sortable item is no longer over a droppable area. Press escape to cancel.';
},
}),
[value],
);
const screenReaderInstructions: ScreenReaderInstructions = useMemo(
() => ({
draggable: `
To pick up a sortable item, press space or enter.
While dragging, use the ${orientation === 'vertical' ? 'up and down' : orientation === 'horizontal' ? 'left and right' : 'arrow'} keys to move the item.
Press space or enter again to drop the item in its new position, or press escape to cancel.
`,
}),
[orientation],
);
const contextValue = useMemo(
() => ({
id,
items,
modifiers: modifiers ?? config.modifiers,
strategy: strategy ?? config.strategy,
activeId,
setActiveId,
getItemValue,
flatCursor,
}),
[
id,
items,
modifiers,
strategy,
config.modifiers,
config.strategy,
activeId,
getItemValue,
flatCursor,
],
);
return (
<SortableRootContext.Provider value={contextValue as SortableRootContextValue<unknown>}>
<DndContext
collisionDetection={collisionDetection ?? config.collisionDetection}
modifiers={modifiers ?? config.modifiers}
sensors={sensors}
{...sortableProps}
id={id}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onDragCancel={onDragCancel}
accessibility={{
announcements,
screenReaderInstructions,
...accessibility,
}}
/>
</SortableRootContext.Provider>
);
}
const SortableContentContext = createContext<boolean>(false);
interface SortableContentProps extends React.ComponentProps<'div'> {
strategy?: SortableContextProps['strategy'];
children: React.ReactNode;
asChild?: boolean;
withoutSlot?: boolean;
}
function SortableContent(props: SortableContentProps) {
const { strategy: strategyProp, asChild, withoutSlot, children, ref, ...contentProps } = props;
const context = useSortableContext(CONTENT_NAME);
const ContentPrimitive = asChild ? Slot : 'div';
return (
<SortableContentContext.Provider value>
<SortableContext items={context.items} strategy={strategyProp ?? context.strategy}>
{withoutSlot ? (
children
) : (
<ContentPrimitive data-slot="sortable-content" {...contentProps} ref={ref}>
{children}
</ContentPrimitive>
)}
</SortableContext>
</SortableContentContext.Provider>
);
}
interface SortableItemContextValue {
id: string;
attributes: DraggableAttributes;
listeners: DraggableSyntheticListeners | undefined;
setActivatorNodeRef: (node: HTMLElement | null) => void;
isDragging?: boolean;
disabled?: boolean;
}
const SortableItemContext = createContext<SortableItemContextValue | null>(null);
function useSortableItemContext(consumerName: string) {
const context = useContext(SortableItemContext);
if (!context) {
throw new Error(`\`${consumerName}\` must be used within \`${ITEM_NAME}\``);
}
return context;
}
interface SortableItemProps extends React.ComponentProps<'div'> {
value: UniqueIdentifier;
asHandle?: boolean;
asChild?: boolean;
disabled?: boolean;
}
function SortableItem(props: SortableItemProps) {
const { value, style, asHandle, asChild, disabled, className, ref, ...itemProps } = props;
const inSortableContent = useContext(SortableContentContext);
const inSortableOverlay = useContext(SortableOverlayContext);
if (!inSortableContent && !inSortableOverlay) {
throw new Error(
`\`${ITEM_NAME}\` must be used within \`${CONTENT_NAME}\` or \`${OVERLAY_NAME}\``,
);
}
if (value === '') {
throw new Error(`\`${ITEM_NAME}\` value cannot be an empty string`);
}
const context = useSortableContext(ITEM_NAME);
const id = useId();
const {
attributes,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: value, disabled });
const composedRef = useComposedRefs(ref as React.Ref<HTMLDivElement>, node => {
if (disabled) return;
setNodeRef(node);
if (asHandle) setActivatorNodeRef(node);
});
const composedStyle = useMemo<React.CSSProperties>(() => {
return {
transform: CSS.Translate.toString(transform),
transition,
...style,
};
}, [transform, transition, style]);
const itemContext = useMemo<SortableItemContextValue>(
() => ({
id,
attributes,
listeners,
setActivatorNodeRef,
isDragging,
disabled,
}),
[id, attributes, listeners, setActivatorNodeRef, isDragging, disabled],
);
const ItemPrimitive = asChild ? Slot : 'div';
return (
<SortableItemContext.Provider value={itemContext}>
<ItemPrimitive
id={id}
data-disabled={disabled}
data-dragging={isDragging ? '' : undefined}
data-slot="sortable-item"
{...itemProps}
{...(asHandle && !disabled ? attributes : {})}
{...(asHandle && !disabled ? listeners : {})}
ref={composedRef}
style={composedStyle}
className={cn(
'focus-visible:ring-ring focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-offset-1',
{
'touch-none select-none': asHandle,
'cursor-default': context.flatCursor,
'data-dragging:cursor-grabbing': !context.flatCursor,
'cursor-grab': !isDragging && asHandle && !context.flatCursor,
'opacity-50': isDragging,
'pointer-events-none opacity-50': disabled,
},
className,
)}
/>
</SortableItemContext.Provider>
);
}
interface SortableItemHandleProps extends React.ComponentProps<'button'> {
asChild?: boolean;
}
function SortableItemHandle(props: SortableItemHandleProps) {
const { asChild, disabled, className, ref, ...itemHandleProps } = props;
const context = useSortableContext(ITEM_HANDLE_NAME);
const itemContext = useSortableItemContext(ITEM_HANDLE_NAME);
const isDisabled = disabled ?? itemContext.disabled;
const composedRef = useComposedRefs(ref as React.Ref<HTMLButtonElement>, node => {
if (!isDisabled) return;
itemContext.setActivatorNodeRef(node);
});
const HandlePrimitive = asChild ? Slot : 'button';
return (
<HandlePrimitive
type="button"
aria-controls={itemContext.id}
data-disabled={isDisabled}
data-dragging={itemContext.isDragging ? '' : undefined}
data-slot="sortable-item-handle"
{...itemHandleProps}
{...(isDisabled ? {} : itemContext.attributes)}
{...(isDisabled ? {} : itemContext.listeners)}
ref={composedRef}
className={cn(
'select-none disabled:pointer-events-none disabled:opacity-50',
context.flatCursor ? 'cursor-default' : 'data-dragging:cursor-grabbing cursor-grab',
className,
)}
disabled={isDisabled}
/>
);
}
const SortableOverlayContext = createContext(false);
const dropAnimation: DropAnimation = {
sideEffects: defaultDropAnimationSideEffects({
styles: {
active: {
opacity: '0.4',
},
},
}),
};
interface SortableOverlayProps extends Omit<React.ComponentProps<typeof DragOverlay>, 'children'> {
container?: Element | DocumentFragment | null;
children?: ((params: { value: UniqueIdentifier }) => React.ReactNode) | React.ReactNode;
}
function SortableOverlay(props: SortableOverlayProps) {
const { container: containerProp, children, ...overlayProps } = props;
const context = useSortableContext(OVERLAY_NAME);
const [mounted, setMounted] = useState(false);
useLayoutEffect(() => setMounted(true), []);
const container = containerProp ?? (mounted ? globalThis.document?.body : null);
if (!container) return null;
return ReactDOM.createPortal(
<DragOverlay
dropAnimation={dropAnimation}
modifiers={context.modifiers}
className={cn(!context.flatCursor && 'cursor-grabbing')}
{...overlayProps}
>
<SortableOverlayContext.Provider value>
{context.activeId
? typeof children === 'function'
? children({ value: context.activeId })
: children
: null}
</SortableOverlayContext.Provider>
</DragOverlay>,
container,
);
}
export {
SortableRoot as Sortable,
SortableContent,
SortableItem,
SortableItemHandle,
SortableOverlay,
//
SortableRoot as Root,
SortableContent as Content,
SortableItem as Item,
SortableItemHandle as ItemHandle,
SortableOverlay as Overlay,
};

View file

@ -0,0 +1,15 @@
import { Loader2Icon } from 'lucide-react';
import { cn } from '@/laboratory/lib/utils';
function Spinner({ className, ...props }: React.ComponentProps<'svg'>) {
return (
<Loader2Icon
role="status"
aria-label="Loading"
className={cn('size-4 animate-spin', className)}
{...props}
/>
);
}
export { Spinner };

View file

@ -0,0 +1,50 @@
import { cn } from '@/laboratory/lib/utils';
import * as TabsPrimitive from '@radix-ui/react-tabs';
function Tabs({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn('flex flex-col gap-2', className)}
{...props}
/>
);
}
function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
'bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]',
className,
)}
{...props}
/>
);
}
function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 whitespace-nowrap rounded-md border border-transparent px-2 py-1 text-sm font-medium transition-[color,box-shadow] focus-visible:outline-1 focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
{...props}
/>
);
}
function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn('flex-1 outline-none', className)}
{...props}
/>
);
}
export { Tabs, TabsList, TabsTrigger, TabsContent };

View file

@ -0,0 +1,42 @@
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/laboratory/lib/utils';
import * as TogglePrimitive from '@radix-ui/react-toggle';
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{
variants: {
variant: {
default: 'bg-transparent',
outline:
'border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground',
},
size: {
default: 'h-9 px-2 min-w-9',
sm: 'h-8 px-1.5 min-w-8',
lg: 'h-10 px-2.5 min-w-10',
},
},
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

@ -0,0 +1,53 @@
import { cn } from '@/laboratory/lib/utils';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
);
}
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
);
}
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
'bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-[var(--radix-tooltip-content-transform-origin)] text-balance rounded-md px-3 py-1.5 text-xs',
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

View file

@ -0,0 +1,9 @@
export * from './components/laboratory/laboratory';
export * from './lib/collections';
export * from './lib/env';
export * from './lib/history';
export * from './lib/operations';
export * from './lib/preflight';
export * from './lib/settings';
export * from './lib/tabs';
export * from './lib/tests';

View file

@ -0,0 +1,220 @@
import { useCallback, useState } from 'react';
import type { LaboratoryOperation } from '@/laboratory/lib/operations';
import type { LaboratoryTabsActions, LaboratoryTabsState } from '@/laboratory/lib/tabs';
export interface LaboratoryCollectionOperation extends LaboratoryOperation {
id: string;
name: string;
description: string;
createdAt: string;
}
export interface LaboratoryCollection {
id: string;
name: string;
description?: string;
createdAt: string;
operations: LaboratoryCollectionOperation[];
}
export interface LaboratoryCollectionsActions {
addCollection: (
collection: Omit<LaboratoryCollection, 'id' | 'createdAt' | 'operations'>,
) => void;
addOperationToCollection: (
collectionId: string,
operation: Omit<LaboratoryCollectionOperation, 'createdAt'>,
) => void;
deleteCollection: (collectionId: string) => void;
deleteOperationFromCollection: (collectionId: string, operationId: string) => void;
updateCollection: (
collectionId: string,
collection: Omit<LaboratoryCollection, 'id' | 'createdAt'>,
) => void;
updateOperationInCollection: (
collectionId: string,
operationId: string,
operation: Omit<LaboratoryCollectionOperation, 'id' | 'createdAt'>,
) => void;
}
export interface LaboratoryCollectionsState {
collections: LaboratoryCollection[];
}
export interface LaboratoryCollectionsCallbacks {
onCollectionCreate?: (collection: LaboratoryCollection) => void;
onCollectionUpdate?: (collection: LaboratoryCollection) => void;
onCollectionDelete?: (collection: LaboratoryCollection) => void;
onCollectionOperationCreate?: (
collection: LaboratoryCollection,
operation: LaboratoryCollectionOperation,
) => void;
onCollectionOperationUpdate?: (
collection: LaboratoryCollection,
operation: LaboratoryCollectionOperation,
) => void;
onCollectionOperationDelete?: (
collection: LaboratoryCollection,
operation: LaboratoryCollectionOperation,
) => void;
}
export const useCollections = (
props: {
defaultCollections?: LaboratoryCollection[];
onCollectionsChange?: (collections: LaboratoryCollection[]) => void;
tabsApi?: LaboratoryTabsState & LaboratoryTabsActions;
} & LaboratoryCollectionsCallbacks,
): LaboratoryCollectionsState & LaboratoryCollectionsActions => {
const [collections, setCollections] = useState<LaboratoryCollection[]>(
props.defaultCollections ?? [],
);
const addCollection = useCallback(
(collection: Omit<LaboratoryCollection, 'id' | 'createdAt' | 'operations'>) => {
const newCollection: LaboratoryCollection = {
...collection,
id: crypto.randomUUID(),
createdAt: new Date().toISOString(),
operations: [],
};
const newCollections = [...collections, newCollection];
setCollections(newCollections);
props.onCollectionsChange?.(newCollections);
props.onCollectionCreate?.(newCollection);
},
[collections, props],
);
const addOperation = useCallback(
(collectionId: string, operation: Omit<LaboratoryCollectionOperation, 'createdAt'>) => {
const newOperation: LaboratoryCollectionOperation = {
...operation,
createdAt: new Date().toISOString(),
};
const newCollections = collections.map(collection =>
collection.id === collectionId
? {
...collection,
operations: [...collection.operations, newOperation],
}
: collection,
);
setCollections(newCollections);
props.onCollectionsChange?.(newCollections);
const updatedCollection = newCollections.find(collection => collection.id === collectionId);
if (updatedCollection) {
props.onCollectionUpdate?.(updatedCollection);
props.onCollectionOperationCreate?.(updatedCollection, newOperation);
}
},
[collections, props],
);
const deleteCollection = useCallback(
(collectionId: string) => {
const collectionToDelete = collections.find(collection => collection.id === collectionId);
const newCollections = collections.filter(collection => collection.id !== collectionId);
setCollections(newCollections);
props.onCollectionsChange?.(newCollections);
if (collectionToDelete) {
props.onCollectionDelete?.(collectionToDelete);
}
},
[collections, props],
);
const deleteOperation = useCallback(
(collectionId: string, operationId: string) => {
let operationToDelete: LaboratoryCollectionOperation | undefined;
const newCollections = collections.map(collection =>
collection.id === collectionId
? {
...collection,
operations: collection.operations.filter(operation => {
if (operation.id === operationId) {
operationToDelete = operation;
return false;
}
return true;
}),
}
: collection,
);
setCollections(newCollections);
props.onCollectionsChange?.(newCollections);
const updatedCollection = newCollections.find(collection => collection.id === collectionId);
if (updatedCollection) {
props.onCollectionUpdate?.(updatedCollection);
if (operationToDelete) {
props.onCollectionOperationDelete?.(updatedCollection, operationToDelete);
}
}
},
[collections, props],
);
const updateCollection = useCallback(
(collectionId: string, collection: Omit<LaboratoryCollection, 'id' | 'createdAt'>) => {
const newCollections = collections.map(c =>
c.id === collectionId ? { ...c, ...collection } : c,
);
setCollections(newCollections);
props.onCollectionsChange?.(newCollections);
const updatedCollection = newCollections.find(collection => collection.id === collectionId);
if (updatedCollection) {
props.onCollectionUpdate?.(updatedCollection);
}
},
[collections, props],
);
const updateOperation = useCallback(
(
collectionId: string,
operationId: string,
operation: Omit<LaboratoryCollectionOperation, 'id' | 'createdAt'>,
) => {
let updatedOperation: LaboratoryCollectionOperation | undefined;
const newCollections = collections.map(c => {
if (c.id !== collectionId) {
return c;
}
return {
...c,
operations: c.operations.map(o => {
if (o.id === operationId) {
updatedOperation = { ...o, ...operation };
return updatedOperation;
}
return o;
}),
};
});
setCollections(newCollections);
props.onCollectionsChange?.(newCollections);
const updatedCollection = newCollections.find(collection => collection.id === collectionId);
if (updatedCollection) {
props.onCollectionUpdate?.(updatedCollection);
if (updatedOperation) {
props.onCollectionOperationUpdate?.(updatedCollection, updatedOperation);
}
}
},
[collections, props],
);
return {
collections,
addCollection,
addOperationToCollection: addOperation,
deleteCollection,
deleteOperationFromCollection: deleteOperation,
updateCollection,
updateOperationInCollection: updateOperation,
};
};

View file

@ -0,0 +1,62 @@
import { useCallback } from 'react';
type PossibleRef<T> = React.Ref<T> | undefined;
/**
* Set a given ref to a given value
* This utility takes care of different types of refs: callback refs and RefObject(s)
*/
function setRef<T>(ref: PossibleRef<T>, value: T) {
if (typeof ref === 'function') {
return ref(value);
}
if (ref !== null && ref !== undefined) {
(ref as React.MutableRefObject<T>).current = value;
}
}
/**
* A utility to compose multiple refs together
* Accepts callback refs and RefObject(s)
*/
function composeRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
return node => {
let hasCleanup = false;
const cleanups = refs.map(ref => {
const cleanup = setRef(ref, node);
if (!hasCleanup && typeof cleanup === 'function') {
hasCleanup = true;
}
return cleanup;
});
// React <19 will log an error to the console if a callback ref returns a
// value. We don't use ref cleanups internally so this will only happen if a
// user's ref callback returns a value, which we only expect if they are
// using the cleanup functionality added in React 19.
if (hasCleanup) {
return () => {
for (let i = 0; i < cleanups.length; i++) {
const cleanup = cleanups[i];
if (typeof cleanup === 'function') {
(cleanup as () => void)();
} else {
setRef(refs[i], null);
}
}
};
}
};
}
/**
* A custom hook that composes multiple refs
* Accepts callback refs and RefObject(s)
*/
function useComposedRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
// biome-ignore lint/correctness/useExhaustiveDependencies: we want to memoize by all values
return useCallback(composeRefs(...refs), refs);
}
export { composeRefs, useComposedRefs };

View file

@ -0,0 +1 @@
export const QUERY_PARAM_PREFIX = 'gl_';

View file

@ -0,0 +1,79 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
buildClientSchema,
getIntrospectionQuery,
GraphQLSchema,
type IntrospectionQuery,
} from 'graphql';
import { toast } from 'sonner';
export interface LaboratoryEndpointState {
endpoint: string | null;
schema: GraphQLSchema | null;
introspection: IntrospectionQuery | null;
}
export interface LaboratoryEndpointActions {
setEndpoint: (endpoint: string) => void;
fetchSchema: () => void;
}
export const useEndpoint = (props: {
defaultEndpoint?: string | null;
onEndpointChange?: (endpoint: string | null) => void;
}): LaboratoryEndpointState & LaboratoryEndpointActions => {
// eslint-disable-next-line react/hook-use-state
const [endpoint, _setEndpoint] = useState<string | null>(props.defaultEndpoint ?? null);
const [introspection, setIntrospection] = useState<IntrospectionQuery | null>(null);
const setEndpoint = useCallback(
(endpoint: string) => {
_setEndpoint(endpoint);
props.onEndpointChange?.(endpoint);
},
[props],
);
const schema = useMemo(() => {
return introspection ? buildClientSchema(introspection) : null;
}, [introspection]);
const fetchSchema = useCallback(async () => {
if (!endpoint) {
setIntrospection(null);
return;
}
try {
const response = await fetch(endpoint, {
method: 'POST',
body: JSON.stringify({
query: getIntrospectionQuery(),
}),
headers: {
'Content-Type': 'application/json',
},
}).then(r => r.json());
setIntrospection(response.data as IntrospectionQuery);
} catch {
toast.error('Failed to fetch schema');
setIntrospection(null);
return;
}
}, [endpoint]);
useEffect(() => {
if (endpoint) {
void fetchSchema();
}
}, [endpoint, fetchSchema]);
return {
endpoint,
setEndpoint,
schema,
introspection,
fetchSchema,
};
};

View file

@ -0,0 +1,34 @@
import { useCallback, useState } from 'react';
export interface LaboratoryEnv {
variables: Record<string, string>;
}
export interface LaboratoryEnvState {
env: LaboratoryEnv | null;
}
export interface LaboratoryEnvActions {
setEnv: (env: LaboratoryEnv) => void;
}
export const useEnv = (props: {
defaultEnv?: LaboratoryEnv | null;
onEnvChange?: (env: LaboratoryEnv | null) => void;
}): LaboratoryEnvState & LaboratoryEnvActions => {
// eslint-disable-next-line react/hook-use-state
const [env, _setEnv] = useState<LaboratoryEnv>(props.defaultEnv ?? { variables: {} });
const setEnv = useCallback(
(env: LaboratoryEnv) => {
_setEnv(env);
props.onEnvChange?.(env);
},
[props],
);
return {
env,
setEnv,
};
};

View file

@ -0,0 +1,163 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { format } from 'date-fns';
import type { LaboratoryOperation } from '@/laboratory/lib/operations';
import type { LaboratoryPreflightLog } from '@/laboratory/lib/preflight';
export interface LaboratoryHistoryRequest {
id: string;
status: number;
duration: number;
size: number;
response: string;
headers: string;
operation: LaboratoryOperation;
preflightLogs?: LaboratoryPreflightLog[];
createdAt: string;
}
export interface LaboratoryHistorySubscription {
id: string;
responses: {
createdAt: string;
data: string;
}[];
preflightLogs?: LaboratoryPreflightLog[];
operation: LaboratoryOperation;
createdAt: string;
}
export type LaboratoryHistory = LaboratoryHistoryRequest | LaboratoryHistorySubscription;
export interface LaboratoryHistoryState {
history: LaboratoryHistory[];
}
export interface LaboratoryHistoryActions {
addHistory: (history: Omit<LaboratoryHistory, 'id'>) => LaboratoryHistory;
addResponseToHistory: (historyId: string, response: string) => void;
deleteHistory: (historyId: string) => void;
deleteHistoryByDay: (day: string) => void;
deleteAllHistory: () => void;
}
export interface LaboratoryHistoryCallbacks {
onHistoryCreate?: (history: LaboratoryHistory) => void;
onHistoryUpdate?: (history: LaboratoryHistory) => void;
onHistoryDelete?: (history: LaboratoryHistory) => void;
}
export const useHistory = (
props: {
defaultHistory?: LaboratoryHistory[];
onHistoryChange?: (history: LaboratoryHistory[]) => void;
} & LaboratoryHistoryCallbacks,
): LaboratoryHistoryState & LaboratoryHistoryActions => {
const [history, setHistory] = useState<LaboratoryHistory[]>(props.defaultHistory ?? []);
const historyRef = useRef<LaboratoryHistory[]>(history);
useEffect(() => {
historyRef.current = history;
}, [history]);
const addHistory = useCallback(
(item: Omit<LaboratoryHistory, 'id'>) => {
const newItem: LaboratoryHistory = {
...item,
id: crypto.randomUUID(),
} as LaboratoryHistory;
const newHistory = [...history, newItem];
setHistory(newHistory);
props.onHistoryChange?.(newHistory);
props.onHistoryCreate?.(newItem);
return newItem;
},
[history, props],
);
const addResponseToHistory = useCallback(
(historyId: string, response: string) => {
const historyItem = historyRef.current.find(item => item.id === historyId);
if (!historyItem) {
return;
}
if ('responses' in historyItem) {
const newResponses = [
...historyItem.responses,
{
createdAt: new Date().toISOString(),
data: response,
},
];
const updatedHistoryItem = {
...historyItem,
responses: newResponses,
};
const newHistory = historyRef.current.map(item =>
item.id === historyId ? updatedHistoryItem : item,
);
setHistory(newHistory);
props.onHistoryChange?.(newHistory);
props.onHistoryUpdate?.(updatedHistoryItem);
}
},
[props],
);
const deleteHistory = useCallback(
(historyId: string) => {
const historyToDelete = historyRef.current.find(item => item.id === historyId);
const newHistory = historyRef.current.filter(item => item.id !== historyId);
setHistory(newHistory);
props.onHistoryChange?.(newHistory);
if (historyToDelete) {
props.onHistoryDelete?.(historyToDelete);
}
},
[props],
);
const deleteAllHistory = useCallback(() => {
const removedItems = [...historyRef.current];
setHistory([]);
props.onHistoryChange?.([]);
if (props.onHistoryDelete) {
for (const item of removedItems) {
props.onHistoryDelete(item);
}
}
}, [props]);
const deleteHistoryByDay = useCallback(
(day: string) => {
const removedItems = historyRef.current.filter(
item => format(new Date(item.createdAt), 'dd MMM yyyy') === day,
);
const newHistory = historyRef.current.filter(
item => format(new Date(item.createdAt), 'dd MMM yyyy') !== day,
);
setHistory(newHistory);
props.onHistoryChange?.(newHistory);
if (props.onHistoryDelete) {
for (const item of removedItems) {
props.onHistoryDelete(item);
}
}
},
[props],
);
return {
history,
addHistory,
addResponseToHistory,
deleteHistory,
deleteAllHistory,
deleteHistoryByDay,
};
};

View file

@ -0,0 +1,460 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { GraphQLSchema } from 'graphql';
import { createClient } from 'graphql-ws';
import { decompressFromEncodedURIComponent } from 'lz-string';
import {
LaboratoryPermission,
LaboratoryPermissions,
} from '@/laboratory/components/laboratory/context';
import type {
LaboratoryCollectionOperation,
LaboratoryCollectionsActions,
LaboratoryCollectionsState,
} from '@/laboratory/lib/collections';
import type { LaboratoryEnv, LaboratoryEnvActions, LaboratoryEnvState } from '@/laboratory/lib/env';
import {
addArgToField,
addPathToQuery,
deletePathFromQuery,
getOperationName,
handleTemplate,
removeArgFromField,
} from '@/laboratory/lib/operations.utils';
import type {
LaboratoryPreflightActions,
LaboratoryPreflightState,
} from '@/laboratory/lib/preflight';
import type { LaboratoryTabsActions, LaboratoryTabsState } from '@/laboratory/lib/tabs';
export interface LaboratoryOperation {
id: string;
name: string;
query: string;
variables: string;
headers: string;
extensions: string;
}
export interface LaboratoryOperationsState {
operations: LaboratoryOperation[];
activeOperation: LaboratoryOperation | null;
}
export interface LaboratoryOperationsActions {
setActiveOperation: (operationId: string) => void;
addOperation: (
operation: Omit<LaboratoryOperation, 'id'> & { id?: string },
) => LaboratoryOperation;
setOperations: (operations: LaboratoryOperation[]) => void;
updateActiveOperation: (operation: Partial<Omit<LaboratoryOperation, 'id'>>) => void;
deleteOperation: (operationId: string) => void;
addPathToActiveOperation: (path: string) => void;
deletePathFromActiveOperation: (path: string) => void;
addArgToActiveOperation: (path: string, argName: string, schema: GraphQLSchema) => void;
deleteArgFromActiveOperation: (path: string, argName: string) => void;
runActiveOperation: (
endpoint: string,
options?: {
env?: LaboratoryEnv;
onResponse?: (response: string) => void;
},
) => Promise<Response | null>;
stopActiveOperation: (() => void) | null;
isActiveOperationLoading: boolean;
isOperationLoading: (operationId: string) => boolean;
isOperationSubscription: (operation: LaboratoryOperation) => boolean;
isActiveOperationSubscription: boolean;
}
export interface LaboratoryOperationsCallbacks {
onOperationCreate?: (operation: LaboratoryOperation) => void;
onOperationUpdate?: (operation: LaboratoryOperation) => void;
onOperationDelete?: (operation: LaboratoryOperation) => void;
}
export const useOperations = (
props: {
checkPermissions: (
permission: `${keyof LaboratoryPermissions & string}:${keyof LaboratoryPermission & string}`,
) => boolean;
defaultOperations?: LaboratoryOperation[];
defaultActiveOperationId?: string;
onOperationsChange?: (operations: LaboratoryOperation[]) => void;
onActiveOperationIdChange?: (operationId: string) => void;
collectionsApi?: LaboratoryCollectionsState & LaboratoryCollectionsActions;
tabsApi?: LaboratoryTabsState & LaboratoryTabsActions;
envApi?: LaboratoryEnvState & LaboratoryEnvActions;
preflightApi?: LaboratoryPreflightState & LaboratoryPreflightActions;
} & LaboratoryOperationsCallbacks,
): LaboratoryOperationsState & LaboratoryOperationsActions => {
// eslint-disable-next-line react/hook-use-state
const [operations, _setOperations] = useState<LaboratoryOperation[]>(
props.defaultOperations ?? [],
);
const activeOperation = useMemo(() => {
const tab = props.tabsApi?.activeTab;
if (!tab) {
return null;
}
if (tab.type === 'operation') {
return operations.find(o => o.id === tab.data.id) ?? null;
}
return null;
}, [props.tabsApi, operations]);
const setActiveOperation = useCallback(
(operationId: string) => {
const tab =
props.tabsApi?.tabs.find(t => t.type === 'operation' && t.data.id === operationId) ?? null;
if (!tab) {
return;
}
props.tabsApi?.setActiveTab(tab);
},
[props.tabsApi],
);
const setOperations = useCallback(
(operations: LaboratoryOperation[]) => {
_setOperations(operations);
props.onOperationsChange?.(operations);
},
[props],
);
const addOperation = useCallback(
(operation: Omit<LaboratoryOperation, 'id'> & { id?: string }) => {
const newOperation = { id: crypto.randomUUID(), ...operation };
const newOperations = [...operations, newOperation];
_setOperations(newOperations);
props.onOperationsChange?.(newOperations);
props.onOperationCreate?.(newOperation);
return newOperation;
},
[operations, props],
);
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const share = urlParams.get('share');
if (share) {
const payload = decompressFromEncodedURIComponent(share);
if (payload) {
const { n, q, v, h, e } = JSON.parse(payload);
const operation = addOperation({
name: n,
query: q,
variables: v,
headers: h,
extensions: e,
});
props.tabsApi?.addTab({
type: 'operation',
data: operation,
});
}
}
}, [addOperation, props.tabsApi]);
const updateActiveOperation = useCallback(
(operation: Partial<Omit<LaboratoryOperation, 'id'>>) => {
const updatedOperation = { ...activeOperation, ...operation };
if (updatedOperation.query) {
const parsedName = getOperationName(updatedOperation.query);
if (parsedName) {
updatedOperation.name = parsedName;
}
}
const newOperations = operations.map(o =>
o.id === activeOperation?.id ? (updatedOperation as LaboratoryOperation) : o,
);
_setOperations(newOperations);
props.onOperationsChange?.(newOperations);
if (updatedOperation.id) {
props.onOperationUpdate?.(updatedOperation as LaboratoryOperation);
}
if (
props.collectionsApi &&
props.checkPermissions?.('collectionsOperations:update') &&
activeOperation?.id
) {
const collectionId =
props.collectionsApi.collections.find(c =>
c.operations.some(o => o.id === activeOperation.id),
)?.id ?? '';
props.collectionsApi.updateOperationInCollection(
collectionId,
activeOperation.id,
updatedOperation as LaboratoryCollectionOperation,
);
}
},
[activeOperation, operations, props.checkPermissions, props.collectionsApi],
);
const deleteOperation = useCallback(
(operationId: string) => {
const operationToDelete = operations.find(o => o.id === operationId);
const newOperations = operations.filter(o => o.id !== operationId);
_setOperations(newOperations);
props.onOperationsChange?.(newOperations);
if (operationToDelete) {
props.onOperationDelete?.(operationToDelete);
}
if (activeOperation?.id === operationId) {
setActiveOperation(newOperations[0]?.id ?? '');
}
},
[activeOperation, operations, props, setActiveOperation],
);
const addPathToActiveOperation = useCallback(
(path: string) => {
if (!activeOperation) {
return;
}
const newActiveOperation = {
...activeOperation,
query: addPathToQuery(activeOperation.query, path),
};
updateActiveOperation(newActiveOperation);
},
[activeOperation, updateActiveOperation],
);
const deletePathFromActiveOperation = useCallback(
(path: string) => {
if (!activeOperation?.query) {
return;
}
const newActiveOperation = {
...activeOperation,
query: deletePathFromQuery(activeOperation.query, path),
};
updateActiveOperation(newActiveOperation);
},
[activeOperation, updateActiveOperation],
);
const addArgToActiveOperation = useCallback(
(path: string, argName: string, schema: GraphQLSchema) => {
if (!activeOperation?.query) {
return;
}
const newActiveOperation = {
...activeOperation,
query: addArgToField(activeOperation.query, path, argName, schema),
};
updateActiveOperation(newActiveOperation);
},
[activeOperation, updateActiveOperation],
);
const deleteArgFromActiveOperation = useCallback(
(path: string, argName: string) => {
if (!activeOperation?.query) {
return;
}
const newActiveOperation = {
...activeOperation,
query: removeArgFromField(activeOperation.query, path, argName),
};
updateActiveOperation(newActiveOperation);
},
[activeOperation, updateActiveOperation],
);
const [stopOperationsFunctions, setStopOperationsFunctions] = useState<
Record<string, () => void>
>({});
const isOperationLoading = useCallback(
(operationId: string) => {
return Object.keys(stopOperationsFunctions).includes(operationId);
},
[stopOperationsFunctions],
);
const isActiveOperationLoading = useMemo(() => {
return activeOperation ? isOperationLoading(activeOperation.id) : false;
}, [activeOperation, isOperationLoading]);
const runActiveOperation = useCallback(
async (
endpoint: string,
options?: {
env?: LaboratoryEnv;
onResponse?: (response: string) => void;
},
) => {
if (!activeOperation?.query) {
return null;
}
let env: LaboratoryEnv = options?.env ??
(await props.preflightApi
?.runPreflight?.()
?.then(result => result?.env ?? { variables: {} })) ?? {
variables: {},
};
if (env && Object.keys(env.variables).length > 0) {
props.envApi?.setEnv(env);
} else {
env = props.envApi?.env ?? { variables: {} };
}
const headers = activeOperation.headers
? JSON.parse(handleTemplate(activeOperation.headers, env))
: {};
const variables = activeOperation.variables
? JSON.parse(handleTemplate(activeOperation.variables, env))
: {};
const extensions = activeOperation.extensions
? JSON.parse(handleTemplate(activeOperation.extensions, env))
: {};
if (activeOperation.query.startsWith('subscription')) {
const client = createClient({
url: endpoint.replace('http', 'ws'),
connectionParams: {
...headers,
},
});
client.on('connected', () => {
console.log('connected');
});
client.on('error', () => {
setStopOperationsFunctions(prev => {
const newStopOperationsFunctions = { ...prev };
delete newStopOperationsFunctions[activeOperation.id];
return newStopOperationsFunctions;
});
});
client.on('closed', () => {
setStopOperationsFunctions(prev => {
const newStopOperationsFunctions = { ...prev };
delete newStopOperationsFunctions[activeOperation.id];
return newStopOperationsFunctions;
});
});
client.subscribe(
{
query: activeOperation.query,
variables,
extensions,
},
{
next: message => {
options?.onResponse?.(JSON.stringify(message ?? {}));
},
error: () => {},
complete: () => {},
},
);
setStopOperationsFunctions(prev => ({
...prev,
[activeOperation.id]: () => {
void client.dispose();
setStopOperationsFunctions(prev => {
const newStopOperationsFunctions = { ...prev };
delete newStopOperationsFunctions[activeOperation.id];
return newStopOperationsFunctions;
});
},
}));
return Promise.resolve(new Response());
}
const abortController = new AbortController();
const response = fetch(endpoint, {
method: 'POST',
body: JSON.stringify({
query: activeOperation.query,
variables,
extensions,
}),
headers: {
...headers,
'Content-Type': 'application/json',
},
signal: abortController.signal,
}).finally(() => {
setStopOperationsFunctions(prev => {
const newStopOperationsFunctions = { ...prev };
delete newStopOperationsFunctions[activeOperation.id];
return newStopOperationsFunctions;
});
});
setStopOperationsFunctions(prev => ({
...prev,
[activeOperation.id]: () => abortController.abort(),
}));
return response;
},
[activeOperation, props.preflightApi, props.envApi],
);
const isOperationSubscription = useCallback((operation: LaboratoryOperation) => {
return operation.query?.startsWith('subscription') ?? false;
}, []);
const isActiveOperationSubscription = useMemo(() => {
return activeOperation ? isOperationSubscription(activeOperation) : false;
}, [activeOperation, isOperationSubscription]);
return {
operations,
setOperations,
runActiveOperation,
setActiveOperation,
activeOperation,
addOperation,
updateActiveOperation,
deleteOperation,
addPathToActiveOperation,
deletePathFromActiveOperation,
addArgToActiveOperation,
deleteArgFromActiveOperation,
isActiveOperationLoading,
stopActiveOperation: stopOperationsFunctions[activeOperation?.id ?? ''],
isActiveOperationSubscription,
isOperationSubscription,
isOperationLoading,
};
};

View file

@ -0,0 +1,917 @@
import {
GraphQLList,
GraphQLNonNull,
GraphQLObjectType,
GraphQLScalarType,
GraphQLSchema,
Kind,
OperationTypeNode,
parse,
print,
visit,
type DefinitionNode,
type DocumentNode,
type FieldNode,
type GraphQLField,
type GraphQLOutputType,
type GraphQLType,
type OperationDefinitionNode,
type SelectionNode,
type VariableDefinitionNode,
} from 'graphql';
import type { Maybe } from 'graphql/jsutils/Maybe';
import type { LaboratoryEnv } from '@/laboratory/lib/env';
import type { LaboratoryOperation } from '@/laboratory/lib/operations';
export function healQuery(query: string) {
return query.replace(/\{(\s+)?\}/g, '');
}
export function isPathInQuery(query: string, path: string, operationName?: string) {
if (!query || !path) {
return false;
}
query = healQuery(query);
const [operation, ...segments] = path.split('.') as [OperationTypeNode, ...string[]];
let doc: DocumentNode | undefined;
try {
doc = parse(query);
} catch {
// console.error(error);
}
if (!doc) {
return false;
}
const operationDefinition: OperationDefinitionNode = doc.definitions.find(v => {
if (v.kind === Kind.OPERATION_DEFINITION && v.operation === operation) {
if (operationName) {
return v.name?.value === operationName;
}
return true;
}
return false;
}) as OperationDefinitionNode;
if (!operationDefinition) {
return false;
}
if (segments.length === 0) {
return true;
}
const currentPath: string[] = [];
let found = false;
visit(operationDefinition, {
Field: {
enter(field) {
currentPath.push(field.name.value);
if (
currentPath.length === segments.length &&
currentPath.every((v, i) => v === segments[i])
) {
found = true;
return false;
}
},
leave() {
currentPath.pop();
},
},
});
return found;
}
export function addPathToQuery(query: string, path: string, operationName?: string) {
query = healQuery(query);
const [operation, ...parts] = path.split('.') as [OperationTypeNode, ...string[]];
let doc: DocumentNode | undefined;
try {
doc = parse(query);
} catch {
// console.error(error);
}
doc ??= {
kind: Kind.DOCUMENT,
definitions: [
{
kind: Kind.OPERATION_DEFINITION,
operation,
name: {
kind: Kind.NAME,
value: 'Untitled',
},
selectionSet: {
kind: Kind.SELECTION_SET,
selections: [],
},
},
],
};
let operationDefinition: OperationDefinitionNode = doc.definitions.find(v => {
if (v.kind === Kind.OPERATION_DEFINITION && v.operation === operation) {
if (operationName) {
return v.name?.value === operationName;
}
return true;
}
return false;
}) as OperationDefinitionNode;
if (!operationDefinition) {
operationDefinition = {
kind: Kind.OPERATION_DEFINITION,
operation,
name: {
kind: Kind.NAME,
value: 'Untitled',
},
selectionSet: {
kind: Kind.SELECTION_SET,
selections: [],
},
};
(doc.definitions as DefinitionNode[]).push(operationDefinition);
}
if (parts.length === 0) {
return print(doc)
.split('\n')
.map(v => {
if (v.includes(`${operation} Untitled`)) {
return v + ' {}';
}
return v;
})
.join('\n');
}
const currentPath: string[] = [];
visit(operationDefinition, {
OperationDefinition: {
enter(operationDefinition) {
const fieldName = parts[0];
// @ts-expect-error temp
operationDefinition.selectionSet ??= {
kind: Kind.SELECTION_SET,
selections: [],
};
let fieldNode: FieldNode = operationDefinition.selectionSet.selections.find(v => {
return v.kind === Kind.FIELD && v.name.value === fieldName;
}) as FieldNode;
if (!fieldNode) {
fieldNode = {
kind: Kind.FIELD,
name: {
kind: Kind.NAME,
value: fieldName,
},
};
(operationDefinition.selectionSet.selections as SelectionNode[]).push(fieldNode);
}
},
},
Field: {
enter(field) {
currentPath.push(field.name.value);
if (currentPath.every((v, i) => v === parts[i])) {
if (currentPath.length === parts.length) {
return false;
}
const fieldName = parts[currentPath.length];
// @ts-expect-error temp
field.selectionSet ??= {
kind: Kind.SELECTION_SET,
selections: [],
};
let fieldNode: FieldNode = field.selectionSet!.selections.find(v => {
return v.kind === Kind.FIELD && v.name.value === fieldName;
}) as FieldNode;
if (!fieldNode) {
fieldNode = {
kind: Kind.FIELD,
name: {
kind: Kind.NAME,
value: fieldName,
},
};
(field.selectionSet!.selections as SelectionNode[]).push(fieldNode);
}
}
},
leave() {
currentPath.pop();
},
},
});
return print(doc);
}
export function deletePathFromQuery(query: string, path: string, operationName?: string) {
query = healQuery(query);
const [operation, ...segments] = path.split('.') as [OperationTypeNode, ...string[]];
let doc: DocumentNode | undefined;
try {
doc = parse(query);
} catch {
// console.error(error);
}
if (!doc) {
return query;
}
let operationDefinition: OperationDefinitionNode = doc.definitions.find(v => {
if (v.kind === Kind.OPERATION_DEFINITION && v.operation === operation) {
if (operationName) {
return v.name?.value === operationName;
}
return true;
}
return false;
}) as OperationDefinitionNode;
if (!operationDefinition) {
return query;
}
const currentPath: string[] = [];
let isOperationSelectionSetEmpty = false;
visit(operationDefinition, {
OperationDefinition: {
enter(operationDefinition) {
if (segments.length === 1) {
const fieldName = segments[0];
if (operationDefinition.selectionSet) {
operationDefinition.selectionSet.selections =
operationDefinition.selectionSet.selections.filter(v => {
return v.kind !== Kind.FIELD || v.name.value !== fieldName;
});
isOperationSelectionSetEmpty = operationDefinition.selectionSet.selections.length === 0;
}
}
},
},
Field: {
enter(field) {
currentPath.push(field.name.value);
if (
currentPath.length === segments.length - 1 &&
currentPath.every((v, i) => v === segments[i])
) {
const fieldName = segments[currentPath.length];
if (field.selectionSet) {
field.selectionSet.selections = field.selectionSet.selections.filter(v => {
return v.kind !== Kind.FIELD || v.name.value !== fieldName;
});
}
}
},
leave() {
currentPath.pop();
},
},
});
if (isOperationSelectionSetEmpty) {
if (doc.definitions.length > 1) {
return `${print({ ...doc, definitions: doc.definitions.filter(v => v !== operationDefinition) })}
${operation} ${operationDefinition.name?.value} {}`;
}
return `${operation} ${operationDefinition.name?.value} {}`;
}
operationDefinition = doc.definitions.find(v => {
if (v.kind === Kind.OPERATION_DEFINITION && v.operation === operation) {
if (operationName) {
return v.name?.value === operationName;
}
return true;
}
return false;
}) as OperationDefinitionNode;
return print(doc);
}
export async function getOperationHash(
operation: Pick<LaboratoryOperation, 'query' | 'variables'>,
) {
try {
console.log(operation.query, operation.variables);
const canonicalQuery = print(parse(operation.query));
const canonicalVariables = '';
const canonical = `${canonicalQuery}\n${canonicalVariables}`;
const encoder = new TextEncoder();
const data = encoder.encode(canonical);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
return hashHex;
} catch {
// console.error(error);
return null;
}
}
export function getOperationName(query: string) {
try {
const doc = parse(query);
const operationDefinition = doc.definitions.find(v => v.kind === Kind.OPERATION_DEFINITION);
return operationDefinition?.name?.value;
} catch {
// console.error(error);
const match = query.match(/(query|mutation|subscription)\s+([a-zA-Z0-9_]+)/);
return match ? match[2] : null;
}
}
export function getOperationType(query: string) {
try {
const doc = parse(query);
const operationDefinition = doc.definitions.find(v => v.kind === Kind.OPERATION_DEFINITION);
return operationDefinition?.operation;
} catch {
return null;
}
}
export function isArgInQuery(query: string, path: string, argName: string, operationName?: string) {
if (!query || !path) {
return false;
}
query = healQuery(query);
const [operation, ...segments] = path.split('.') as [OperationTypeNode, ...string[]];
let doc: DocumentNode | undefined;
try {
doc = parse(query);
} catch {
// console.error(error);
}
if (!doc) {
return false;
}
const operationDefinition: OperationDefinitionNode = doc.definitions.find(v => {
if (v.kind === Kind.OPERATION_DEFINITION && v.operation === operation) {
if (operationName) {
return v.name?.value === operationName;
}
return true;
}
return false;
}) as OperationDefinitionNode;
if (!operationDefinition) {
return false;
}
const currentPath: string[] = [];
let found = false;
visit(operationDefinition, {
Field: {
enter(field) {
currentPath.push(field.name.value);
if (
currentPath.length === segments.length &&
currentPath.every((v, i) => v === segments[i]) &&
field.arguments
) {
found = field.arguments.some(v => v.name.value === argName);
}
},
leave() {
currentPath.pop();
},
},
});
return found;
}
export function extractOfType(
type: GraphQLOutputType,
): GraphQLObjectType | GraphQLScalarType | null {
if (type instanceof GraphQLNonNull) {
return extractOfType(type.ofType);
}
if (type instanceof GraphQLNonNull) {
return extractOfType(type.ofType);
}
if (type instanceof GraphQLList) {
return extractOfType(type.ofType);
}
if (type instanceof GraphQLObjectType) {
return type;
}
if (type instanceof GraphQLScalarType) {
return type;
}
return null;
}
export function findFieldInSchema(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;
}
for (let i = 0; i < segments.length; ++i) {
if (!type) {
return;
}
if (type instanceof GraphQLObjectType) {
const field = type.getFields()[segments[i]] as GraphQLField<unknown, unknown, unknown>;
if (!field) {
return;
}
if (i === segments.length - 1) {
return field;
}
type = extractOfType(field.type);
}
}
return null;
}
export function addArgToField(
query: string,
path: string,
argName: string,
schema: GraphQLSchema,
operationName?: string,
) {
query = healQuery(query);
const [operation, ...segments] = path.split('.') as [OperationTypeNode, ...string[]];
let doc: DocumentNode | undefined;
try {
doc = parse(query);
} catch {
// console.error(error);
}
doc ||= {
kind: Kind.DOCUMENT,
definitions: [
{
kind: Kind.OPERATION_DEFINITION,
operation,
name: {
kind: Kind.NAME,
value: 'NewOperation',
},
selectionSet: {
kind: Kind.SELECTION_SET,
selections: [],
},
},
],
};
let operationDefinition: OperationDefinitionNode = doc.definitions.find(v => {
if (v.kind === Kind.OPERATION_DEFINITION && v.operation === operation) {
if (operationName) {
return v.name?.value === operationName;
}
return true;
}
return false;
}) as OperationDefinitionNode;
if (!operationDefinition) {
operationDefinition = {
kind: Kind.OPERATION_DEFINITION,
operation,
name: {
kind: Kind.NAME,
value: 'NewOperation',
},
selectionSet: {
kind: Kind.SELECTION_SET,
selections: [],
},
};
(doc.definitions as DefinitionNode[]).push(operationDefinition);
}
query = print(doc);
if (!isPathInQuery(query, path, operationName)) {
doc = parse(addPathToQuery(query, path, operationName));
operationDefinition = doc.definitions.find(v => {
if (v.kind === Kind.OPERATION_DEFINITION && v.operation === operation) {
if (operationName) {
return v.name?.value === operationName;
}
return true;
}
return false;
}) as OperationDefinitionNode;
}
const currentPath: string[] = [];
visit(operationDefinition, {
Field: {
enter(field) {
currentPath.push(field.name.value);
if (
currentPath.length === segments.length - 1 &&
currentPath.every((v, i) => v === segments[i])
) {
const fieldName = segments[currentPath.length];
if (field.selectionSet) {
const typeField = findFieldInSchema(
[operation, ...currentPath, fieldName].join('.'),
schema,
);
if (typeField?.args) {
const arg = typeField.args.find(v => v.name === argName);
if (arg) {
// @ts-expect-error temp
operationDefinition.variableDefinitions ||= [];
let variableName = arg.name;
let i = 2;
while (
(operationDefinition.variableDefinitions as VariableDefinitionNode[]).find(
v => v.variable.name.value === variableName,
)
) {
variableName = arg.name + i;
++i;
}
(operationDefinition.variableDefinitions as VariableDefinitionNode[]).push({
kind: Kind.VARIABLE_DEFINITION,
variable: {
kind: Kind.VARIABLE,
name: {
kind: Kind.NAME,
value: variableName,
},
},
type: {
kind: Kind.NAMED_TYPE,
name: {
kind: Kind.NAME,
value: arg.type.toString(),
},
},
});
const fieldNode: FieldNode = field.selectionSet.selections.find(v => {
return v.kind === Kind.FIELD && v.name.value === fieldName;
}) as FieldNode;
if (fieldNode) {
// @ts-expect-error temp
fieldNode.arguments ||= [];
// @ts-expect-error temp
fieldNode.arguments.push({
kind: Kind.ARGUMENT,
name: {
kind: Kind.NAME,
value: argName,
},
value: {
kind: Kind.VARIABLE,
name: {
kind: Kind.NAME,
value: variableName,
},
},
});
}
}
}
}
}
},
leave() {
currentPath.pop();
},
},
OperationDefinition: {
enter(operationDefinition) {
if (segments.length === 1) {
const fieldName = segments[0];
if (operationDefinition.selectionSet) {
const typeField = findFieldInSchema(
[operation, ...currentPath, fieldName].join('.'),
schema,
);
if (typeField?.args) {
const arg = typeField.args.find(v => v.name === argName);
if (arg) {
// @ts-expect-error temp
operationDefinition.variableDefinitions ||= [];
let variableName = arg.name;
let i = 2;
while (
(operationDefinition.variableDefinitions as VariableDefinitionNode[]).find(
v => v.variable.name.value === variableName,
)
) {
variableName = arg.name + i;
++i;
}
(operationDefinition.variableDefinitions as VariableDefinitionNode[]).push({
kind: Kind.VARIABLE_DEFINITION,
variable: {
kind: Kind.VARIABLE,
name: {
kind: Kind.NAME,
value: variableName,
},
},
type: {
kind: Kind.NAMED_TYPE,
name: {
kind: Kind.NAME,
value: arg.type.toString(),
},
},
});
const fieldNode: FieldNode = operationDefinition.selectionSet.selections.find(v => {
return v.kind === Kind.FIELD && v.name.value === fieldName;
}) as FieldNode;
if (fieldNode) {
// @ts-expect-error temp
fieldNode.arguments ||= [];
// @ts-expect-error temp
fieldNode.arguments.push({
kind: Kind.ARGUMENT,
name: {
kind: Kind.NAME,
value: argName,
},
value: {
kind: Kind.VARIABLE,
name: {
kind: Kind.NAME,
value: variableName,
},
},
});
}
}
}
}
}
},
},
});
return print(doc);
}
export function removeArgFromField(
query: string,
path: string,
argName: string,
operationName?: string,
) {
query = healQuery(query);
const [operation, ...segments] = path.split('.') as [OperationTypeNode, ...string[]];
let doc: DocumentNode | undefined;
try {
doc = parse(query);
} catch {
// console.error(error);
}
if (!doc) {
return query;
}
const operationDefinition: OperationDefinitionNode = doc.definitions.find(v => {
if (v.kind === Kind.OPERATION_DEFINITION && v.operation === operation) {
if (operationName) {
return v.name?.value === operationName;
}
return true;
}
return false;
}) as OperationDefinitionNode;
if (!operationDefinition) {
return query;
}
const currentPath: string[] = [];
visit(operationDefinition, {
Field: {
enter(field) {
currentPath.push(field.name.value);
if (
currentPath.length === segments.length - 1 &&
currentPath.every((v, i) => v === segments[i])
) {
const fieldName = segments[currentPath.length];
if (field.selectionSet) {
const fieldNode: FieldNode = field.selectionSet.selections.find(v => {
return v.kind === Kind.FIELD && v.name.value === fieldName;
}) as FieldNode;
if (fieldNode?.arguments) {
// @ts-expect-error temp
fieldNode.arguments = fieldNode.arguments.filter(v => {
return v.kind !== Kind.ARGUMENT || v.name.value !== argName;
});
}
}
}
},
leave() {
currentPath.pop();
},
},
OperationDefinition: {
enter(operationDefinition) {
if (segments.length === 1) {
const fieldName = segments[0];
if (operationDefinition.selectionSet) {
const fieldNode: FieldNode = operationDefinition.selectionSet.selections.find(v => {
return v.kind === Kind.FIELD && v.name.value === fieldName;
}) as FieldNode;
if (fieldNode?.arguments) {
// @ts-expect-error temp
fieldNode.arguments = fieldNode.arguments.filter(v => {
return v.kind !== Kind.ARGUMENT || v.name.value !== argName;
});
}
}
}
},
},
});
return print(doc);
}
export function extractPaths(query: string): string[][] {
try {
const ast = parse(query);
const paths: string[][] = [
[
ast.definitions[0].kind === Kind.OPERATION_DEFINITION
? ast.definitions[0].operation
: 'query',
],
];
const traverse = (selections: readonly SelectionNode[], currentPath: string[] = []) => {
for (const selection of selections) {
if (selection.kind === 'Field') {
const newPath = [...currentPath, selection.name.value];
paths.push(newPath);
if (selection.selectionSet) {
traverse(selection.selectionSet.selections, newPath);
}
}
}
};
for (const def of ast.definitions) {
if (def.kind === 'OperationDefinition' && def.selectionSet) {
traverse(def.selectionSet.selections, paths[0]);
}
}
return paths;
} catch {
return [];
}
}
export function getOpenPaths(query: string): string[] {
return extractPaths(query).map(v => v.join('.'));
}
export function handleTemplate(query: string, env: LaboratoryEnv) {
return query.replace(/\{\{(.*?)\}\}/g, (match, p1) => {
return env.variables[p1] ?? match;
});
}

View file

@ -0,0 +1,199 @@
import { useCallback, useState } from 'react';
import type { LaboratoryEnv, LaboratoryEnvActions, LaboratoryEnvState } from '@/laboratory/lib/env';
export interface LaboratoryPreflightLog {
level: 'log' | 'warn' | 'error';
message: unknown[];
createdAt: string;
}
export interface LaboratoryPreflightResult {
status: 'success' | 'error';
error?: string;
logs: LaboratoryPreflightLog[];
env: LaboratoryEnv;
}
export interface LaboratoryPreflight {
script: string;
lastTestResult?: LaboratoryPreflightResult | null;
}
export interface LaboratoryPreflightState {
preflight: LaboratoryPreflight | null;
}
export interface LaboratoryPreflightActions {
setPreflight: (preflight: LaboratoryPreflight) => void;
runPreflight: () => Promise<LaboratoryPreflightResult | null>;
setLastTestResult: (result: LaboratoryPreflightResult | null) => void;
}
export const usePreflight = (props: {
defaultPreflight?: LaboratoryPreflight | null;
onPreflightChange?: (preflight: LaboratoryPreflight | null) => void;
envApi: LaboratoryEnvState & LaboratoryEnvActions;
}): LaboratoryPreflightState & LaboratoryPreflightActions => {
// eslint-disable-next-line react/hook-use-state
const [preflight, _setPreflight] = useState<LaboratoryPreflight | null>(
props.defaultPreflight ?? null,
);
const setPreflight = useCallback(
(preflight: LaboratoryPreflight) => {
_setPreflight(preflight);
props.onPreflightChange?.(preflight);
},
[props],
);
const runPreflight = useCallback(async () => {
if (!preflight) {
return null;
}
return runIsolatedLabScript(preflight.script, props.envApi?.env ?? { variables: {} });
}, [preflight, props.envApi.env]);
const setLastTestResult = useCallback(
(result: LaboratoryPreflightResult | null) => {
_setPreflight({ ...(preflight ?? { script: '' }), lastTestResult: result });
props.onPreflightChange?.({ ...(preflight ?? { script: '' }), lastTestResult: result });
},
[preflight, props],
);
return {
preflight,
setPreflight,
runPreflight,
setLastTestResult,
};
};
export async function runIsolatedLabScript(
script: string,
env: LaboratoryEnv,
prompt?: (placeholder: string, defaultValue: string) => Promise<string | null>,
): Promise<LaboratoryPreflightResult> {
return new Promise((resolve, reject) => {
const blob = new Blob(
[
/* javascript */ `
import CryptoJS from 'https://cdn.jsdelivr.net/npm/crypto-js@4.2.0/+esm';
const env = ${JSON.stringify(env)};
let promptResolve = null;
self.onmessage = async (event) => {
if (event.data.type === 'prompt:result') {
promptResolve?.(event.data.value || null);
}
if (event.data.type === 'init') {
try {
self.console = {
log: (...args) => {
self.postMessage({ type: 'log', level: 'log', message: args });
},
warn: (...args) => {
self.postMessage({ type: 'log', level: 'warn', message: args });
},
error: (...args) => {
self.postMessage({ type: 'log', level: 'error', message: args });
},
};
const lab = Object.freeze({
request: (endpoint, query, options) => {
return fetch(endpoint, {
method: 'POST',
body: JSON.stringify({ query, variables: options?.variables, extensions: options?.extensions }),
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
});
},
environment: {
get: (key) => env.variables[key],
set: (key, value) => {
env.variables[key] = value;
},
delete: (key) => {
delete env.variables[key];
}
},
prompt: (placeholder, defaultValue) => {
return new Promise((resolve) => {
promptResolve = resolve;
self.postMessage({ type: 'prompt', placeholder, defaultValue });
});
},
CryptoJS: CryptoJS
});
// Make CryptoJS available globally in the script context
const AsyncFunction = async function () {}.constructor;
await new AsyncFunction('lab', 'CryptoJS', 'with(lab){' + event.data.script + '}')(lab, CryptoJS);
self.postMessage({ type: 'result', status: 'success', env: env });
} catch (err) {
self.postMessage({ type: 'result', status: 'error', error: err.message || String(err) });
}
}
};
`,
],
{ type: 'application/javascript' },
);
const logs: LaboratoryPreflightLog[] = [];
const worker = new Worker(URL.createObjectURL(blob), { type: 'module' });
worker.onmessage = ({ data }) => {
if (data.type === 'result') {
worker.terminate();
if (data.status === 'success') {
resolve({
status: 'success',
logs,
env: data.env,
});
} else if (data.status === 'error') {
console.error(data.error);
reject({
status: 'error',
error: data.error,
logs,
});
}
} else if (data.type === 'log') {
if (data.level === 'log') {
logs.push({ level: 'log', message: data.message, createdAt: new Date().toISOString() });
} else if (data.level === 'warn') {
logs.push({ level: 'warn', message: data.message, createdAt: new Date().toISOString() });
} else if (data.level === 'error') {
logs.push({ level: 'error', message: data.message, createdAt: new Date().toISOString() });
}
} else if (data.type === 'prompt') {
void prompt?.(data.placeholder, data.defaultValue).then(value => {
worker.postMessage({ type: 'prompt:result', value });
});
}
};
worker.onerror = error => {
reject({
status: 'error',
error: error.message,
logs,
});
};
worker.postMessage({ type: 'init', script });
});
}

View file

@ -0,0 +1,32 @@
import { useCallback, useState } from 'react';
export type LaboratorySettings = object;
export interface LaboratorySettingsState {
settings: LaboratorySettings;
}
export interface LaboratorySettingsActions {
setSettings: (settings: LaboratorySettings) => void;
}
export const useSettings = (props: {
defaultSettings?: LaboratorySettings | null;
onSettingsChange?: (settings: LaboratorySettings | null) => void;
}): LaboratorySettingsState & LaboratorySettingsActions => {
// eslint-disable-next-line react/hook-use-state
const [settings, _setSettings] = useState<LaboratorySettings>(props.defaultSettings ?? {});
const setSettings = useCallback(
(settings: LaboratorySettings) => {
_setSettings(settings);
props.onSettingsChange?.(settings);
},
[props],
);
return {
settings,
setSettings,
};
};

View file

@ -0,0 +1,147 @@
import { useCallback, useState } from 'react';
import type { LaboratoryEnv } from '@/laboratory/lib/env';
import type { LaboratoryHistoryRequest } from '@/laboratory/lib/history';
import type { LaboratoryOperation } from '@/laboratory/lib/operations';
import type { LaboratoryPreflight } from '@/laboratory/lib/preflight';
import type { LaboratoryTest } from '@/laboratory/lib/tests';
export interface LaboratoryTabOperation {
id: string;
type: 'operation';
data: Pick<LaboratoryOperation, 'id' | 'name'>;
readOnly?: boolean;
}
export interface LaboratoryTabHistory {
id: string;
type: 'history';
data: Pick<LaboratoryHistoryRequest, 'id'>;
readOnly?: boolean;
}
export interface LaboratoryTabPreflight {
id: string;
type: 'preflight';
data: LaboratoryPreflight;
readOnly?: boolean;
}
export interface LaboratoryTabEnv {
id: string;
type: 'env';
data: LaboratoryEnv;
readOnly?: boolean;
}
export interface LaboratoryTabTest {
id: string;
type: 'test';
data: Pick<LaboratoryTest, 'id' | 'name'>;
readOnly?: boolean;
}
export interface LaboratoryTabSettings {
id: string;
type: 'settings';
data: unknown;
readOnly?: boolean;
}
export type LaboratoryTabData =
| Pick<LaboratoryOperation, 'id' | 'name'>
| Pick<LaboratoryHistoryRequest, 'id'>
| LaboratoryPreflight
| LaboratoryEnv;
export type LaboratoryTab =
| LaboratoryTabOperation
| LaboratoryTabPreflight
| LaboratoryTabEnv
| LaboratoryTabHistory
| LaboratoryTabSettings
| LaboratoryTabTest;
export interface LaboratoryTabsState {
tabs: LaboratoryTab[];
}
export interface LaboratoryTabsActions {
activeTab: LaboratoryTab | null;
setActiveTab: (tab: LaboratoryTab) => void;
setTabs: (tabs: LaboratoryTab[]) => void;
addTab: (tab: Omit<LaboratoryTab, 'id'>) => LaboratoryTab;
updateTab: (id: string, data: LaboratoryTabData) => void;
deleteTab: (tabId: string) => void;
}
export const useTabs = (props: {
defaultTabs?: LaboratoryTab[] | null;
defaultActiveTabId?: string | null;
onTabsChange?: (tabs: LaboratoryTab[]) => void;
onActiveTabIdChange?: (tabId: string | null) => void;
}): LaboratoryTabsState & LaboratoryTabsActions => {
// eslint-disable-next-line react/hook-use-state
const [tabs, _setTabs] = useState<LaboratoryTab[]>(props.defaultTabs ?? []);
// eslint-disable-next-line react/hook-use-state
const [activeTab, _setActiveTab] = useState<LaboratoryTab | null>(
props.defaultTabs?.find(t => t.id === props.defaultActiveTabId) ??
props.defaultTabs?.[0] ??
null,
);
const setActiveTab = useCallback(
(tab: LaboratoryTab) => {
_setActiveTab(tab);
props.onActiveTabIdChange?.(tab?.id ?? null);
},
[props],
);
const setTabs = useCallback(
(tabs: LaboratoryTab[]) => {
_setTabs(tabs);
props.onTabsChange?.(tabs);
},
[props],
);
const addTab = useCallback(
(tab: Omit<LaboratoryTab, 'id'>) => {
const newTab = { ...tab, id: crypto.randomUUID() } as LaboratoryTab;
const newTabs = [...(tabs ?? []), newTab] as LaboratoryTab[];
_setTabs(newTabs);
props.onTabsChange?.(newTabs);
return newTab;
},
[tabs, props],
);
const deleteTab = useCallback(
(tabId: string) => {
const newTabs = tabs.filter(t => t.id !== tabId);
_setTabs(newTabs);
props.onTabsChange?.(newTabs);
},
[tabs, props],
);
const updateTab = useCallback(
(id: string, newData: LaboratoryTabData) => {
const newTabs = tabs.map(t => (t.id === id ? { ...t, data: newData } : t)) as LaboratoryTab[];
_setTabs(newTabs);
props.onTabsChange?.(newTabs);
},
[tabs, props],
);
return {
activeTab,
setActiveTab,
tabs,
setTabs,
addTab,
deleteTab,
updateTab,
};
};

View file

@ -0,0 +1,117 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { LaboratoryOperation } from '@/laboratory/lib/operations';
export interface LaboratoryTestTaskBase {
id: string;
next: string | null;
}
export interface LaboratoryTestTaskOperation extends LaboratoryTestTaskBase {
type: 'operation';
data: Pick<LaboratoryOperation, 'id'>;
}
export interface LaboratoryTestTaskUtlity extends LaboratoryTestTaskBase {
type: 'utility';
data: unknown;
}
export type LaboratoryTestTask = LaboratoryTestTaskOperation | LaboratoryTestTaskUtlity;
export interface LaboratoryTest {
id: string;
name: string;
description?: string;
createdAt: string;
tasks: LaboratoryTestTask[];
}
export interface LaboratoryTestState {
tests: LaboratoryTest[];
}
export interface LaboratoryTestActions {
addTest: (test: Omit<LaboratoryTest, 'id' | 'createdAt' | 'tasks'>) => LaboratoryTest;
addTaskToTest: (testId: string, task: Pick<LaboratoryTestTask, 'type' | 'data'>) => void;
deleteTaskFromTest: (testId: string, taskId: string) => void;
deleteTest: (testId: string) => void;
}
export const useTests = (props: {
defaultTests?: LaboratoryTest[];
onTestsChange?: (test: LaboratoryTest[]) => void;
}): LaboratoryTestState & LaboratoryTestActions => {
const [tests, setTests] = useState<LaboratoryTest[]>(props.defaultTests ?? []);
const testRef = useRef<LaboratoryTest[]>(tests);
useEffect(() => {
testRef.current = tests;
}, [tests]);
const addTest = useCallback(
(item: Omit<LaboratoryTest, 'id' | 'createdAt' | 'tasks'>) => {
const newItem: LaboratoryTest = {
...item,
id: crypto.randomUUID(),
createdAt: new Date().toISOString(),
tasks: [],
} as LaboratoryTest;
const newTest = [...tests, newItem];
setTests(newTest);
props.onTestsChange?.(newTest);
return newItem;
},
[tests, props],
);
const deleteTest = useCallback(
(testId: string) => {
const newTest = testRef.current.filter(item => item.id !== testId);
setTests(newTest);
props.onTestsChange?.(newTest);
},
[props],
);
const addTaskToTest = useCallback(
(testId: string, task: Pick<LaboratoryTestTask, 'type' | 'data'>) => {
const newTask: LaboratoryTestTask = {
...task,
id: crypto.randomUUID(),
next: null,
} as LaboratoryTestTask;
const newTest = testRef.current.map(item =>
item.id === testId ? { ...item, tasks: [...item.tasks, newTask] } : item,
);
setTests(newTest);
props.onTestsChange?.(newTest);
},
[props],
);
const deleteTaskFromTest = useCallback(
(testId: string, taskId: string) => {
const newTest = testRef.current.map(item =>
item.id === testId
? { ...item, tasks: item.tasks.filter(task => task.id !== taskId) }
: item,
);
setTests(newTest);
props.onTestsChange?.(newTest);
},
[props],
);
return {
tests,
addTest,
deleteTest,
addTaskToTest,
deleteTaskFromTest,
};
};

View file

@ -0,0 +1,10 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function capitalize(str: string) {
return str.charAt(0).toUpperCase() + str.slice(1);
}

View file

@ -0,0 +1,689 @@
import { useCallback, useMemo } from 'react';
import clsx from 'clsx';
import { throttle } from 'lodash';
import { useMutation, useQuery } from 'urql';
import { Page, TargetLayout } from '@/components/layouts/target';
import { ConnectLabModal } from '@/components/target/laboratory/connect-lab-modal';
import { Button } from '@/components/ui/button';
import { DocsLink } from '@/components/ui/docs-note';
import { Meta } from '@/components/ui/meta';
import { Subtitle, Title } from '@/components/ui/page';
import { ToggleGroup, ToggleGroupItem } from '@/components/v2/toggle-group';
import { graphql, useFragment } from '@/gql';
import {
Laboratory,
LaboratoryCollection,
LaboratoryCollectionOperation,
LaboratoryHistory,
LaboratoryOperation,
LaboratoryPreflight,
LaboratoryTab,
} from '@/laboratory';
import { LaboratoryApi } from '@/laboratory/components/laboratory/context';
import { useRedirect } from '@/lib/access/common';
import { useToggle } from '@/lib/hooks';
import { TargetLaboratoryPageQuery } from '@/lib/hooks/laboratory/use-operation-collections-plugin';
import { useResetState } from '@/lib/hooks/use-reset-state';
import { cn } from '@/lib/utils';
import { Link as RouterLink } from '@tanstack/react-router';
function useApiTabValueState(graphqlEndpointUrl: string | null) {
const [state, setState] = useResetState<'mockApi' | 'linkedApi'>(() => {
const value = localStorage.getItem('hive:laboratory-tab-value');
if (!value || !['mockApi', 'linkedApi'].includes(value)) {
return graphqlEndpointUrl ? 'linkedApi' : 'mockApi';
}
if (value === 'linkedApi' && graphqlEndpointUrl) {
return 'linkedApi';
}
return 'mockApi';
}, [graphqlEndpointUrl]);
return [
state,
useCallback(
(state: 'mockApi' | 'linkedApi') => {
localStorage.setItem('hive:laboratory-tab-value', state);
setState(state);
},
[setState],
),
] as const;
}
const localStoragePrefix = 'hive:laboratory:';
const getLocalStorageState = (key: string, defaultValue: any) => {
const value = localStorage.getItem(`${localStoragePrefix}${key}`);
return value ? JSON.parse(value) : defaultValue;
};
const setLocalStorageState = (key: string, value: any) => {
localStorage.setItem(`${localStoragePrefix}${key}`, JSON.stringify(value));
};
export const LaboratoryPreflightScriptTargetFragment = graphql(`
fragment LaboratoryPreflightScriptTargetFragment on Target {
id
preflightScript {
id
sourceCode
}
viewerCanModifyPreflightScript
}
`);
export const LaboratoryQuery = graphql(`
query Laboratory($selector: TargetSelectorInput!) {
target(reference: { bySelector: $selector }) {
id
documentCollections {
edges {
cursor
node {
id
name
description
operations(first: 100) {
edges {
node {
id
name
query
variables
headers
}
cursor
}
}
}
}
}
...LaboratoryPreflightScriptTargetFragment
viewerCanModifyLaboratory
viewerCanViewLaboratory
}
}
`);
export const CreateCollectionMutation = graphql(`
mutation LaboratoryCreateCollection(
$selector: TargetSelectorInput!
$input: CreateDocumentCollectionInput!
) {
createDocumentCollection(selector: $selector, input: $input) {
error {
message
}
ok {
updatedTarget {
id
documentCollections {
edges {
cursor
node {
id
name
}
}
}
}
collection {
id
name
operations(first: 100) {
edges {
cursor
node {
id
name
}
cursor
}
}
}
}
}
}
`);
const UpdateOperationMutation = graphql(`
mutation LaboratoryUpdateOperation(
$selector: TargetSelectorInput!
$input: UpdateDocumentCollectionOperationInput!
) {
updateOperationInDocumentCollection(selector: $selector, input: $input) {
error {
message
}
ok {
operation {
id
name
query
variables
headers
}
}
}
}
`);
const CreateOperationMutation = graphql(`
mutation LaboratoryCreateOperation(
$selector: TargetSelectorInput!
$input: CreateDocumentCollectionOperationInput!
) {
createOperationInDocumentCollection(selector: $selector, input: $input) {
error {
message
}
ok {
operation {
id
name
}
updatedTarget {
id
documentCollections {
edges {
cursor
node {
id
operations {
edges {
node {
id
}
cursor
}
}
}
}
}
}
}
}
}
`);
export const DeleteCollectionMutation = graphql(`
mutation LaboratoryDeleteCollection($selector: TargetSelectorInput!, $id: ID!) {
deleteDocumentCollection(selector: $selector, id: $id) {
error {
message
}
ok {
deletedId
updatedTarget {
id
documentCollections {
edges {
cursor
node {
id
}
}
}
}
}
}
}
`);
export const DeleteOperationMutation = graphql(`
mutation LaboratoryDeleteOperation($selector: TargetSelectorInput!, $id: ID!) {
deleteOperationInDocumentCollection(selector: $selector, id: $id) {
error {
message
}
ok {
deletedId
updatedTarget {
id
documentCollections {
edges {
cursor
node {
id
operations {
edges {
node {
id
}
cursor
}
}
}
}
}
}
}
}
}
`);
export const UpdatePreflightScriptMutation = graphql(`
mutation LaboratoryUpdatePreflightScript($input: UpdatePreflightScriptInput!) {
updatePreflightScript(input: $input) {
ok {
updatedTarget {
id
preflightScript {
id
sourceCode
}
}
}
error {
message
}
}
}
`);
function useLaboratoryState(props: {
organizationSlug: string;
projectSlug: string;
targetSlug: string;
}): Partial<LaboratoryApi> & { fetching: boolean } {
const [{ data, fetching }] = useQuery({
query: LaboratoryQuery,
variables: {
selector: {
targetSlug: props.targetSlug,
organizationSlug: props.organizationSlug,
projectSlug: props.projectSlug,
},
},
});
const preflight = useFragment(LaboratoryPreflightScriptTargetFragment, data?.target ?? null);
const collections = useMemo(
() =>
data?.target?.documentCollections.edges
.map(v => v.node)
.map(
collection =>
({
id: collection.id,
name: collection.name,
createdAt: new Date().toISOString(),
operations: collection.operations.edges
.map(v => v.node)
.map(
operation =>
({
id: operation.id,
name: operation.name,
query: operation.query,
variables: operation.variables ?? '{}',
headers: operation.headers ?? '{}',
extensions: '{}',
description: '',
createdAt: new Date().toISOString(),
}) satisfies LaboratoryCollectionOperation,
),
}) satisfies LaboratoryCollection,
),
[data?.target?.documentCollections.edges],
);
const [, mutateUpdate] = useMutation(UpdateOperationMutation);
const updateOperation = useMemo(
() =>
throttle((collection: LaboratoryCollection, operation: LaboratoryCollectionOperation) => {
void mutateUpdate({
selector: {
targetSlug: props.targetSlug,
organizationSlug: props.organizationSlug,
projectSlug: props.projectSlug,
},
input: {
operationId: operation.id,
collectionId: collection.id,
name: operation.name,
query: operation.query,
variables: operation.variables,
headers: operation.headers,
},
});
}, 1000),
[mutateUpdate, props.targetSlug, props.organizationSlug, props.projectSlug],
);
const [, mutateCreate] = useMutation(CreateOperationMutation);
const createOperation = useMemo(
() =>
throttle((collection: LaboratoryCollection, operation: LaboratoryCollectionOperation) => {
void mutateCreate({
selector: {
targetSlug: props.targetSlug,
organizationSlug: props.organizationSlug,
projectSlug: props.projectSlug,
},
input: {
collectionId: collection.id,
name: operation.name,
query: operation.query,
variables: operation.variables,
headers: operation.headers,
},
});
}, 1000),
[mutateCreate, props.targetSlug, props.organizationSlug, props.projectSlug],
);
const [, mutateDelete] = useMutation(DeleteOperationMutation);
const deleteOperation = useMemo(
() =>
throttle((collection: LaboratoryCollection, operation: LaboratoryCollectionOperation) => {
void mutateDelete({
selector: {
targetSlug: props.targetSlug,
organizationSlug: props.organizationSlug,
projectSlug: props.projectSlug,
},
id: operation.id,
});
}, 1000),
[mutateDelete, props.targetSlug, props.organizationSlug, props.projectSlug],
);
const [, mutateDeleteCollection] = useMutation(DeleteCollectionMutation);
const deleteCollection = useMemo(
() =>
throttle((collection: LaboratoryCollection) => {
void mutateDeleteCollection({
selector: {
targetSlug: props.targetSlug,
organizationSlug: props.organizationSlug,
projectSlug: props.projectSlug,
},
id: collection.id,
});
}, 1000),
[mutateDeleteCollection, props.targetSlug, props.organizationSlug, props.projectSlug],
);
const [, mutateAddCollection] = useMutation(CreateCollectionMutation);
const addCollection = useMemo(
() =>
throttle((collection: LaboratoryCollection) => {
void mutateAddCollection({
selector: {
targetSlug: props.targetSlug,
organizationSlug: props.organizationSlug,
projectSlug: props.projectSlug,
},
input: {
name: collection.name,
description: collection.description,
},
});
}, 1000),
[mutateAddCollection, props.targetSlug, props.organizationSlug, props.projectSlug],
);
const [, mutateUpdatePreflight] = useMutation(UpdatePreflightScriptMutation);
const updatePreflight = useMemo(
() =>
throttle((preflight: LaboratoryPreflight) => {
void mutateUpdatePreflight({
input: {
selector: {
targetSlug: props.targetSlug,
organizationSlug: props.organizationSlug,
projectSlug: props.projectSlug,
},
sourceCode: preflight.script,
},
});
}, 1000),
[mutateUpdatePreflight, props.targetSlug, props.organizationSlug, props.projectSlug],
);
return {
fetching,
defaultCollections: collections,
defaultOperations: getLocalStorageState('operations', []),
defaultHistory: getLocalStorageState('history', []),
defaultTabs: getLocalStorageState('tabs', []),
defaultActiveTabId: getLocalStorageState('activeTabId', null),
defaultPreflight: preflight?.preflightScript?.sourceCode
? { script: preflight.preflightScript.sourceCode }
: null,
onOperationsChange: (operations: LaboratoryOperation[]) => {
setLocalStorageState('operations', operations);
},
onHistoryChange: (history: LaboratoryHistory[]) => {
setLocalStorageState('history', history);
},
onTabsChange: (tabs: LaboratoryTab[]) => {
setLocalStorageState('tabs', tabs);
},
onActiveTabIdChange: (activeTabId: string | null) => {
setLocalStorageState('activeTabId', activeTabId);
},
onCollectionOperationCreate: (
collection: LaboratoryCollection,
operation: LaboratoryCollectionOperation,
) => {
createOperation(collection, operation);
},
onCollectionOperationUpdate: (
collection: LaboratoryCollection,
operation: LaboratoryCollectionOperation,
) => {
updateOperation(collection, operation);
},
onCollectionOperationDelete: (
collection: LaboratoryCollection,
operation: LaboratoryCollectionOperation,
) => {
deleteOperation(collection, operation);
},
onCollectionDelete: (collection: LaboratoryCollection) => {
deleteCollection(collection);
},
onCollectionCreate: (collection: LaboratoryCollection) => {
addCollection(collection);
},
onPreflightChange: (preflight: LaboratoryPreflight | null) => {
updatePreflight(preflight ?? { script: '' });
},
permissions: {
preflight: {
update: preflight?.viewerCanModifyPreflightScript === true,
},
collections: {
create: data?.target?.viewerCanModifyLaboratory === true,
delete: data?.target?.viewerCanModifyLaboratory === true,
},
collectionsOperations: {
create: data?.target?.viewerCanModifyLaboratory === true,
update: data?.target?.viewerCanModifyLaboratory === true,
delete: data?.target?.viewerCanModifyLaboratory === true,
},
},
};
}
function LaboratoryPageContent(props: {
organizationSlug: string;
projectSlug: string;
targetSlug: string;
selectedOperationId?: string;
}) {
const laboratoryState = useLaboratoryState({
organizationSlug: props.organizationSlug,
projectSlug: props.projectSlug,
targetSlug: props.targetSlug,
});
const [query] = useQuery({
query: TargetLaboratoryPageQuery,
variables: {
organizationSlug: props.organizationSlug,
projectSlug: props.projectSlug,
targetSlug: props.targetSlug,
},
});
const [isConnectLabModalOpen, toggleConnectLabModal] = useToggle();
const [actualSelectedApiEndpoint, setEndpointType] = useApiTabValueState(
query.data?.target?.graphqlEndpointUrl ?? null,
);
const mockEndpoint = `${location.origin}/api/lab/${props.organizationSlug}/${props.projectSlug}/${props.targetSlug}`;
const url =
(actualSelectedApiEndpoint === 'linkedApi'
? query.data?.target?.graphqlEndpointUrl
: undefined) ?? mockEndpoint;
useRedirect({
canAccess: query.data?.target?.viewerCanViewLaboratory === true,
redirectTo: router => {
void router.navigate({
to: '/$organizationSlug/$projectSlug/$targetSlug',
params: {
organizationSlug: props.organizationSlug,
projectSlug: props.projectSlug,
targetSlug: props.targetSlug,
},
});
},
entity: query.data?.target,
});
if (laboratoryState.fetching) {
return null;
}
return (
<>
<ConnectLabModal
endpoint={mockEndpoint}
close={toggleConnectLabModal}
isOpen={isConnectLabModalOpen}
isCDNEnabled={query.data ?? null}
/>
<div className="flex size-full flex-col gap-3 py-6">
<div className="flex">
<div className="flex-1">
<Title>Laboratory</Title>
<Subtitle>
Explore your GraphQL schema and run queries against your GraphQL API.
</Subtitle>
<p>
<DocsLink
className="text-muted-foreground text-sm"
href="/schema-registry/laboratory"
>
Learn more about the Laboratory
</DocsLink>
</p>
</div>
<div className="ml-auto mr-0 flex flex-col justify-center">
<div>
{query.data && !query.data.target?.graphqlEndpointUrl ? (
<RouterLink
to="/$organizationSlug/$projectSlug/$targetSlug/settings"
params={{
organizationSlug: props.organizationSlug,
projectSlug: props.projectSlug,
targetSlug: props.targetSlug,
}}
search={{ page: 'general' }}
>
<Button variant="outline" className="mr-2" size="sm">
Connect GraphQL API Endpoint
</Button>
</RouterLink>
) : null}
<Button onClick={toggleConnectLabModal} variant="ghost" size="sm">
Mock Data Endpoint
</Button>
</div>
<div className="self-end pt-2">
<span className="mr-2 text-xs font-bold">Query</span>
<ToggleGroup
defaultValue="list"
onValueChange={newValue => {
setEndpointType(newValue as 'mockApi' | 'linkedApi');
}}
value="mock"
type="single"
className="bg-gray-900/50 text-gray-500"
>
<ToggleGroupItem
key="mockApi"
value="mockApi"
title="Use Mock Schema"
className={clsx(
'text-xs hover:text-white',
!query.fetching &&
actualSelectedApiEndpoint === 'mockApi' &&
'bg-gray-800 text-white',
)}
disabled={query.fetching}
>
Mock
</ToggleGroupItem>
<ToggleGroupItem
key="linkedApi"
value="linkedApi"
title="Use API endpoint"
className={cn(
'text-xs hover:text-white',
!query.fetching &&
actualSelectedApiEndpoint === 'linkedApi' &&
'bg-gray-800 text-white',
)}
disabled={!query.data?.target?.graphqlEndpointUrl || query.fetching}
>
API
</ToggleGroupItem>
</ToggleGroup>
</div>
</div>
</div>
<div className="flex-1 overflow-hidden rounded-lg border">
<Laboratory key={url} defaultEndpoint={url} {...laboratoryState} />
</div>
</div>
</>
);
}
export function TargetLaboratoryPage(props: {
organizationSlug: string;
projectSlug: string;
targetSlug: string;
selectedOperationId: string | undefined;
}) {
return (
<>
<Meta title="Schema laboratory" />
<TargetLayout
organizationSlug={props.organizationSlug}
projectSlug={props.projectSlug}
targetSlug={props.targetSlug}
page={Page.Laboratory}
className="flex h-[--content-height] flex-col pb-0"
>
<LaboratoryPageContent {...props} />
</TargetLayout>
</>
);
}

View file

@ -73,6 +73,7 @@ import { TargetInsightsClientPage } from './pages/target-insights-client';
import { TargetInsightsCoordinatePage } from './pages/target-insights-coordinate';
import { TargetInsightsOperationPage } from './pages/target-insights-operation';
import { TargetLaboratoryPage } from './pages/target-laboratory';
import { TargetLaboratoryPage as TargetLaboratoryPageNew } from './pages/target-laboratory-new';
import { TargetSettingsPage, TargetSettingsPageEnum } from './pages/target-settings';
import { TargetTracePage } from './pages/target-trace';
import {
@ -585,6 +586,24 @@ const targetLaboratoryRoute = createRoute({
},
});
const targetLaboratoryNewRoute = createRoute({
getParentRoute: () => targetRoute,
path: 'laboratory-new',
validateSearch: () => ({}) as { operation?: string; operationString?: string },
component: function TargetLaboratoryNewRoute() {
const { organizationSlug, projectSlug, targetSlug } = targetLaboratoryNewRoute.useParams();
const { operation } = targetLaboratoryNewRoute.useSearch();
return (
<TargetLaboratoryPageNew
organizationSlug={organizationSlug}
projectSlug={projectSlug}
targetSlug={targetSlug}
selectedOperationId={operation}
/>
);
},
});
const targetAppsRoute = createRoute({
getParentRoute: () => targetRoute,
path: 'apps',
@ -934,6 +953,7 @@ const routeTree = root.addChildren([
targetIndexRoute,
targetSettingsRoute,
targetLaboratoryRoute,
targetLaboratoryNewRoute,
targetHistoryRoute.addChildren([targetHistoryVersionRoute]),
targetInsightsRoute,
targetTraceRoute,

View file

@ -62,6 +62,7 @@ async function main() {
dev: true,
spa: true,
});
await server.vite.ready();
} else {
server.log.info('Running in production mode');

View file

@ -79,6 +79,12 @@ const config: Config = {
cyan: '#0acccc',
blue: colors.sky,
gray: colors.stone,
rose: colors.rose,
pink: colors.pink,
teal: colors.teal,
indigo: colors.indigo,
amber: colors.amber,
lime: colors.lime,
magenta: '#f11197',
orange: {
50: '#fefbf5',

View file

@ -1,5 +1,6 @@
import { resolve } from 'node:path';
import type { Plugin, UserConfig } from 'vite';
import monacoEditor from 'vite-plugin-monaco-editor';
import tsconfigPaths from 'vite-tsconfig-paths';
import react from '@vitejs/plugin-react';
@ -22,7 +23,21 @@ const reactScanPlugin: Plugin = {
export default {
root: __dirname,
plugins: [tsconfigPaths(), react(), reactScanPlugin],
plugins: [
tsconfigPaths(),
react(),
reactScanPlugin,
// @ts-expect-error temp
monacoEditor.default({
languageWorkers: ['json', 'typescript', 'editorWorkerService'],
customWorkers: [
{
label: 'graphql',
entry: 'monaco-graphql/dist/graphql.worker',
},
],
}),
],
build: {
rollupOptions: {
input: {

View file

@ -1,3 +1,29 @@
diff --git a/html.js b/html.js
index 479125b6e98296863245c3f226ce3edf57af5f8b..b1e2b63f33d290e63ec65e425dbad03c1aa384af 100644
--- a/html.js
+++ b/html.js
@@ -35,10 +35,10 @@ function createHtmlTemplateFunction(source) {
// biome-ignore lint/style/noCommaOperator: indirect call to eval() to ensure global scope
const compiledTemplatingFunction = (0, eval)(
// biome-ignore lint/style/useTemplate: needed for compiling
- `(asReadable) => (function ({ ${[
- ...new Set(params.map((s) => s.split('.')[0])),
- ].join(', ')} }) {` +
- `return asReadable\`${interpolated.map((s) => serialize(s)).join('')}\`` +
+ `(asReadable) => (function ({ ${[...new Set(params.map(s => s.split('.')[0]))].join(
+ ', ',
+ )} }) {` +
+ `return asReadable\`${interpolated.map(s => serialize(s)).join('')}\`` +
'})',
)(asReadable)
@@ -75,5 +75,5 @@ function serialize(frag) {
if (typeof frag === 'object') {
return `$\{${frag.param}}`
}
- return frag
+ return frag.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${')
}
diff --git a/mode/development.js b/mode/development.js
index af9de9d75a3689cd4f4b5d2876f2e38bd2674ae4..94ecb29a8e0d2615b1ecd0114dba7f3979dc2b11 100644
--- a/mode/development.js

File diff suppressed because it is too large Load diff