mirror of
https://github.com/graphql-hive/console
synced 2026-04-21 14:37:17 +00:00
feat: new hive lab (#7374)
Co-authored-by: Piotr Monwid-Olechnowicz <hasparus@gmail.com>
This commit is contained in:
parent
645c3019db
commit
b4df418ce2
67 changed files with 11412 additions and 696 deletions
|
|
@ -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",
|
||||
|
|
|
|||
22
packages/web/app/components.json
Normal file
22
packages/web/app/components.json
Normal 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": {}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
38
packages/web/app/src/laboratory/components/graphql-type.tsx
Normal file
38
packages/web/app/src/laboratory/components/graphql-type.tsx
Normal 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>;
|
||||
};
|
||||
45
packages/web/app/src/laboratory/components/icons.tsx
Normal file
45
packages/web/app/src/laboratory/components/icons.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
201
packages/web/app/src/laboratory/components/laboratory/editor.tsx
Normal file
201
packages/web/app/src/laboratory/components/laboratory/editor.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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} />;
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
export const Settings = () => {
|
||||
return (
|
||||
<div className="size-full p-3">
|
||||
<div className="mx-auto max-w-2xl" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
460
packages/web/app/src/laboratory/components/laboratory/tabs.tsx
Normal file
460
packages/web/app/src/laboratory/components/laboratory/tabs.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
67
packages/web/app/src/laboratory/components/tabs.tsx
Normal file
67
packages/web/app/src/laboratory/components/tabs.tsx
Normal 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;
|
||||
131
packages/web/app/src/laboratory/components/ui/alert-dialog.tsx
Normal file
131
packages/web/app/src/laboratory/components/ui/alert-dialog.tsx
Normal 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,
|
||||
};
|
||||
37
packages/web/app/src/laboratory/components/ui/badge.tsx
Normal file
37
packages/web/app/src/laboratory/components/ui/badge.tsx
Normal 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 };
|
||||
|
|
@ -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 };
|
||||
56
packages/web/app/src/laboratory/components/ui/button.tsx
Normal file
56
packages/web/app/src/laboratory/components/ui/button.tsx
Normal 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 };
|
||||
73
packages/web/app/src/laboratory/components/ui/card.tsx
Normal file
73
packages/web/app/src/laboratory/components/ui/card.tsx
Normal 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 };
|
||||
25
packages/web/app/src/laboratory/components/ui/checkbox.tsx
Normal file
25
packages/web/app/src/laboratory/components/ui/checkbox.tsx
Normal 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 };
|
||||
|
|
@ -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 };
|
||||
159
packages/web/app/src/laboratory/components/ui/command.tsx
Normal file
159
packages/web/app/src/laboratory/components/ui/command.tsx
Normal 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,
|
||||
};
|
||||
220
packages/web/app/src/laboratory/components/ui/context-menu.tsx
Normal file
220
packages/web/app/src/laboratory/components/ui/context-menu.tsx
Normal 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,
|
||||
};
|
||||
125
packages/web/app/src/laboratory/components/ui/dialog.tsx
Normal file
125
packages/web/app/src/laboratory/components/ui/dialog.tsx
Normal 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,
|
||||
};
|
||||
224
packages/web/app/src/laboratory/components/ui/dropdown-menu.tsx
Normal file
224
packages/web/app/src/laboratory/components/ui/dropdown-menu.tsx
Normal 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,
|
||||
};
|
||||
93
packages/web/app/src/laboratory/components/ui/empty.tsx
Normal file
93
packages/web/app/src/laboratory/components/ui/empty.tsx
Normal 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 };
|
||||
231
packages/web/app/src/laboratory/components/ui/field.tsx
Normal file
231
packages/web/app/src/laboratory/components/ui/field.tsx
Normal 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,
|
||||
};
|
||||
19
packages/web/app/src/laboratory/components/ui/input.tsx
Normal file
19
packages/web/app/src/laboratory/components/ui/input.tsx
Normal 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 };
|
||||
17
packages/web/app/src/laboratory/components/ui/label.tsx
Normal file
17
packages/web/app/src/laboratory/components/ui/label.tsx
Normal 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 };
|
||||
47
packages/web/app/src/laboratory/components/ui/resizable.tsx
Normal file
47
packages/web/app/src/laboratory/components/ui/resizable.tsx
Normal 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 };
|
||||
|
|
@ -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 };
|
||||
168
packages/web/app/src/laboratory/components/ui/select.tsx
Normal file
168
packages/web/app/src/laboratory/components/ui/select.tsx
Normal 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,
|
||||
};
|
||||
26
packages/web/app/src/laboratory/components/ui/separator.tsx
Normal file
26
packages/web/app/src/laboratory/components/ui/separator.tsx
Normal 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 };
|
||||
38
packages/web/app/src/laboratory/components/ui/sonner.tsx
Normal file
38
packages/web/app/src/laboratory/components/ui/sonner.tsx
Normal 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 };
|
||||
542
packages/web/app/src/laboratory/components/ui/sortable.tsx
Normal file
542
packages/web/app/src/laboratory/components/ui/sortable.tsx
Normal 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,
|
||||
};
|
||||
15
packages/web/app/src/laboratory/components/ui/spinner.tsx
Normal file
15
packages/web/app/src/laboratory/components/ui/spinner.tsx
Normal 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 };
|
||||
50
packages/web/app/src/laboratory/components/ui/tabs.tsx
Normal file
50
packages/web/app/src/laboratory/components/ui/tabs.tsx
Normal 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 };
|
||||
42
packages/web/app/src/laboratory/components/ui/toggle.tsx
Normal file
42
packages/web/app/src/laboratory/components/ui/toggle.tsx
Normal 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 };
|
||||
53
packages/web/app/src/laboratory/components/ui/tooltip.tsx
Normal file
53
packages/web/app/src/laboratory/components/ui/tooltip.tsx
Normal 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 };
|
||||
9
packages/web/app/src/laboratory/index.ts
Normal file
9
packages/web/app/src/laboratory/index.ts
Normal 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';
|
||||
220
packages/web/app/src/laboratory/lib/collections.ts
Normal file
220
packages/web/app/src/laboratory/lib/collections.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
62
packages/web/app/src/laboratory/lib/compose-refs.ts
Normal file
62
packages/web/app/src/laboratory/lib/compose-refs.ts
Normal 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 };
|
||||
1
packages/web/app/src/laboratory/lib/constants.ts
Normal file
1
packages/web/app/src/laboratory/lib/constants.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const QUERY_PARAM_PREFIX = 'gl_';
|
||||
79
packages/web/app/src/laboratory/lib/endpoint.ts
Normal file
79
packages/web/app/src/laboratory/lib/endpoint.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
34
packages/web/app/src/laboratory/lib/env.ts
Normal file
34
packages/web/app/src/laboratory/lib/env.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
163
packages/web/app/src/laboratory/lib/history.ts
Normal file
163
packages/web/app/src/laboratory/lib/history.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
460
packages/web/app/src/laboratory/lib/operations.ts
Normal file
460
packages/web/app/src/laboratory/lib/operations.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
917
packages/web/app/src/laboratory/lib/operations.utils.ts
Normal file
917
packages/web/app/src/laboratory/lib/operations.utils.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
199
packages/web/app/src/laboratory/lib/preflight.ts
Normal file
199
packages/web/app/src/laboratory/lib/preflight.ts
Normal 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 });
|
||||
});
|
||||
}
|
||||
32
packages/web/app/src/laboratory/lib/settings.ts
Normal file
32
packages/web/app/src/laboratory/lib/settings.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
147
packages/web/app/src/laboratory/lib/tabs.ts
Normal file
147
packages/web/app/src/laboratory/lib/tabs.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
117
packages/web/app/src/laboratory/lib/tests.ts
Normal file
117
packages/web/app/src/laboratory/lib/tests.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
10
packages/web/app/src/laboratory/lib/utils.ts
Normal file
10
packages/web/app/src/laboratory/lib/utils.ts
Normal 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);
|
||||
}
|
||||
689
packages/web/app/src/pages/target-laboratory-new.tsx
Normal file
689
packages/web/app/src/pages/target-laboratory-new.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ async function main() {
|
|||
dev: true,
|
||||
spa: true,
|
||||
});
|
||||
|
||||
await server.vite.ready();
|
||||
} else {
|
||||
server.log.info('Running in production mode');
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
2052
pnpm-lock.yaml
2052
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue