From abe1455a41c53794134cfa80c5b2edf5d2e34f73 Mon Sep 17 00:00:00 2001 From: Mykhailo Skorokhodov Date: Thu, 4 Dec 2025 14:43:14 +0100 Subject: [PATCH 01/12] feat: new hive lab --- packages/web/app/components.json | 22 + packages/web/app/package.json | 32 +- packages/web/app/src/index.css | 6 + .../labaratory/components/graphql-type.tsx | 49 + .../app/src/labaratory/components/icons.tsx | 45 + .../components/labaratory/builder.tsx | 617 +++++ .../components/labaratory/collections.tsx | 360 +++ .../components/labaratory/command.tsx | 167 ++ .../components/labaratory/context.tsx | 175 ++ .../components/labaratory/editor.tsx | 197 ++ .../labaratory/components/labaratory/env.tsx | 28 + .../components/labaratory/history-item.tsx | 21 + .../components/labaratory/history.tsx | 305 +++ .../labaratory/labaratory-mobile.tsx | 395 +++ .../components/labaratory/labaratory.tsx | 739 ++++++ .../components/labaratory/operation.tsx | 738 ++++++ .../components/labaratory/preflight.tsx | 284 ++ .../components/labaratory/settings.tsx | 7 + .../labaratory/components/labaratory/tabs.tsx | 466 ++++ .../app/src/labaratory/components/tabs.tsx | 68 + .../labaratory/components/ui/alert-dialog.tsx | 132 + .../src/labaratory/components/ui/badge.tsx | 38 + .../labaratory/components/ui/button-group.tsx | 77 + .../src/labaratory/components/ui/button.tsx | 58 + .../app/src/labaratory/components/ui/card.tsx | 74 + .../src/labaratory/components/ui/checkbox.tsx | 26 + .../labaratory/components/ui/collapsible.tsx | 31 + .../src/labaratory/components/ui/command.tsx | 160 ++ .../labaratory/components/ui/context-menu.tsx | 221 ++ .../src/labaratory/components/ui/dialog.tsx | 126 + .../components/ui/dropdown-menu.tsx | 225 ++ .../src/labaratory/components/ui/empty.tsx | 93 + .../src/labaratory/components/ui/field.tsx | 231 ++ .../src/labaratory/components/ui/input.tsx | 20 + .../src/labaratory/components/ui/label.tsx | 18 + .../labaratory/components/ui/resizable.tsx | 48 + .../labaratory/components/ui/scroll-area.tsx | 53 + .../src/labaratory/components/ui/select.tsx | 169 ++ .../labaratory/components/ui/separator.tsx | 27 + .../src/labaratory/components/ui/sonner.tsx | 38 + .../src/labaratory/components/ui/sortable.tsx | 534 ++++ .../src/labaratory/components/ui/spinner.tsx | 15 + .../app/src/labaratory/components/ui/tabs.tsx | 51 + .../src/labaratory/components/ui/toggle.tsx | 43 + .../src/labaratory/components/ui/tooltip.tsx | 54 + packages/web/app/src/labaratory/index.ts | 9 + .../web/app/src/labaratory/lib/collections.ts | 220 ++ .../app/src/labaratory/lib/compose-refs.ts | 62 + .../web/app/src/labaratory/lib/constants.ts | 1 + .../web/app/src/labaratory/lib/endpoint.ts | 78 + packages/web/app/src/labaratory/lib/env.ts | 34 + .../web/app/src/labaratory/lib/history.ts | 163 ++ .../web/app/src/labaratory/lib/operations.ts | 460 ++++ .../src/labaratory/lib/operations.utils.ts | 917 +++++++ .../web/app/src/labaratory/lib/preflight.ts | 199 ++ .../web/app/src/labaratory/lib/settings.ts | 32 + packages/web/app/src/labaratory/lib/tabs.ts | 147 ++ packages/web/app/src/labaratory/lib/tests.ts | 117 + packages/web/app/src/labaratory/lib/utils.ts | 10 + .../app/src/pages/target-laboratory-new.tsx | 734 ++++++ packages/web/app/src/router.tsx | 2 +- packages/web/app/src/server/index.ts | 1 + packages/web/app/tailwind.config.ts | 6 + packages/web/app/vite.config.ts | 34 +- pnpm-lock.yaml | 2333 ++++++++++++----- 65 files changed, 12230 insertions(+), 612 deletions(-) create mode 100644 packages/web/app/components.json create mode 100644 packages/web/app/src/labaratory/components/graphql-type.tsx create mode 100644 packages/web/app/src/labaratory/components/icons.tsx create mode 100644 packages/web/app/src/labaratory/components/labaratory/builder.tsx create mode 100644 packages/web/app/src/labaratory/components/labaratory/collections.tsx create mode 100644 packages/web/app/src/labaratory/components/labaratory/command.tsx create mode 100644 packages/web/app/src/labaratory/components/labaratory/context.tsx create mode 100644 packages/web/app/src/labaratory/components/labaratory/editor.tsx create mode 100644 packages/web/app/src/labaratory/components/labaratory/env.tsx create mode 100644 packages/web/app/src/labaratory/components/labaratory/history-item.tsx create mode 100644 packages/web/app/src/labaratory/components/labaratory/history.tsx create mode 100644 packages/web/app/src/labaratory/components/labaratory/labaratory-mobile.tsx create mode 100644 packages/web/app/src/labaratory/components/labaratory/labaratory.tsx create mode 100644 packages/web/app/src/labaratory/components/labaratory/operation.tsx create mode 100644 packages/web/app/src/labaratory/components/labaratory/preflight.tsx create mode 100644 packages/web/app/src/labaratory/components/labaratory/settings.tsx create mode 100644 packages/web/app/src/labaratory/components/labaratory/tabs.tsx create mode 100644 packages/web/app/src/labaratory/components/tabs.tsx create mode 100644 packages/web/app/src/labaratory/components/ui/alert-dialog.tsx create mode 100644 packages/web/app/src/labaratory/components/ui/badge.tsx create mode 100644 packages/web/app/src/labaratory/components/ui/button-group.tsx create mode 100644 packages/web/app/src/labaratory/components/ui/button.tsx create mode 100644 packages/web/app/src/labaratory/components/ui/card.tsx create mode 100644 packages/web/app/src/labaratory/components/ui/checkbox.tsx create mode 100644 packages/web/app/src/labaratory/components/ui/collapsible.tsx create mode 100644 packages/web/app/src/labaratory/components/ui/command.tsx create mode 100644 packages/web/app/src/labaratory/components/ui/context-menu.tsx create mode 100644 packages/web/app/src/labaratory/components/ui/dialog.tsx create mode 100644 packages/web/app/src/labaratory/components/ui/dropdown-menu.tsx create mode 100644 packages/web/app/src/labaratory/components/ui/empty.tsx create mode 100644 packages/web/app/src/labaratory/components/ui/field.tsx create mode 100644 packages/web/app/src/labaratory/components/ui/input.tsx create mode 100644 packages/web/app/src/labaratory/components/ui/label.tsx create mode 100644 packages/web/app/src/labaratory/components/ui/resizable.tsx create mode 100644 packages/web/app/src/labaratory/components/ui/scroll-area.tsx create mode 100644 packages/web/app/src/labaratory/components/ui/select.tsx create mode 100644 packages/web/app/src/labaratory/components/ui/separator.tsx create mode 100644 packages/web/app/src/labaratory/components/ui/sonner.tsx create mode 100644 packages/web/app/src/labaratory/components/ui/sortable.tsx create mode 100644 packages/web/app/src/labaratory/components/ui/spinner.tsx create mode 100644 packages/web/app/src/labaratory/components/ui/tabs.tsx create mode 100644 packages/web/app/src/labaratory/components/ui/toggle.tsx create mode 100644 packages/web/app/src/labaratory/components/ui/tooltip.tsx create mode 100644 packages/web/app/src/labaratory/index.ts create mode 100644 packages/web/app/src/labaratory/lib/collections.ts create mode 100644 packages/web/app/src/labaratory/lib/compose-refs.ts create mode 100644 packages/web/app/src/labaratory/lib/constants.ts create mode 100644 packages/web/app/src/labaratory/lib/endpoint.ts create mode 100644 packages/web/app/src/labaratory/lib/env.ts create mode 100644 packages/web/app/src/labaratory/lib/history.ts create mode 100644 packages/web/app/src/labaratory/lib/operations.ts create mode 100644 packages/web/app/src/labaratory/lib/operations.utils.ts create mode 100644 packages/web/app/src/labaratory/lib/preflight.ts create mode 100644 packages/web/app/src/labaratory/lib/settings.ts create mode 100644 packages/web/app/src/labaratory/lib/tabs.ts create mode 100644 packages/web/app/src/labaratory/lib/tests.ts create mode 100644 packages/web/app/src/labaratory/lib/utils.ts create mode 100644 packages/web/app/src/pages/target-laboratory-new.tsx diff --git a/packages/web/app/components.json b/packages/web/app/components.json new file mode 100644 index 00000000000..6beb616dc76 --- /dev/null +++ b/packages/web/app/components.json @@ -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": "@/labaratory/components", + "utils": "@/labaratory/lib/utils", + "ui": "@/labaratory/components/ui", + "lib": "@/labaratory/lib", + "hooks": "@/labaratory/hooks" + }, + "registries": {} +} diff --git a/packages/web/app/package.json b/packages/web/app/package.json index d537bc42f32..f6019d9e760 100644 --- a/packages/web/app/package.json +++ b/packages/web/app/package.json @@ -12,7 +12,12 @@ "typecheck": "tsc --noEmit" }, "devDependencies": { + "@dagrejs/dagre": "^2.0.1", "@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 +29,15 @@ "@graphql-typed-document-node/core": "3.2.0", "@headlessui/react": "2.2.0", "@hookform/resolvers": "3.10.0", - "@monaco-editor/react": "4.7.0", + "@mlc-ai/web-llm": "^0.2.80", + "@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 +53,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 +69,11 @@ "@storybook/react-vite": "8.4.7", "@stripe/react-stripe-js": "3.1.1", "@stripe/stripe-js": "5.5.0", + "@tailwindcss/vite": "^4.1.17", + "@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", @@ -94,22 +105,29 @@ "dotenv": "16.4.7", "echarts": "5.6.0", "echarts-for-react": "3.0.2", + "esbuild": "0.25.9", "fastify": "4.29.1", "formik": "2.4.6", "framer-motion": "11.18.2", "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", + "monacopilot": "^1.2.9", + "next-themes": "^0.4.6", "query-string": "9.1.1", "react": "18.3.1", "react-day-picker": "8.10.1", @@ -120,6 +138,7 @@ "react-icons": "5.4.0", "react-resizable-panels": "2.1.7", "react-select": "5.9.0", + "react-shadow": "^20.6.0", "react-string-replace": "1.1.1", "react-textarea-autosize": "8.5.9", "react-toastify": "10.0.6", @@ -129,6 +148,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", @@ -141,7 +161,8 @@ "urql": "4.1.0", "use-debounce": "10.0.4", "valtio": "1.13.2", - "vite": "7.1.11", + "vite": "npm:rolldown-vite@7.1.14", + "vite-plugin-monaco-editor": "^1.1.0", "vite-tsconfig-paths": "5.1.4", "wonka": "6.3.4", "yup": "1.6.1", @@ -151,5 +172,10 @@ "external": [ "vite" ] + }, + "pnpm": { + "overrides": { + "vite": "npm:rolldown-vite@7.1.14" + } } } diff --git a/packages/web/app/src/index.css b/packages/web/app/src/index.css index a2fd28a677f..12bbf099348 100644 --- a/packages/web/app/src/index.css +++ b/packages/web/app/src/index.css @@ -101,6 +101,12 @@ --chart-1: 220 70% 50%; --chart-2: 340 75% 55%; } + + .hive-labaratory { + --primary: 40 89% 60%; + --background: 223 70% 4%; + --card: 220 21.43% 5.49%; + } } @layer base { diff --git a/packages/web/app/src/labaratory/components/graphql-type.tsx b/packages/web/app/src/labaratory/components/graphql-type.tsx new file mode 100644 index 00000000000..7bede95eab3 --- /dev/null +++ b/packages/web/app/src/labaratory/components/graphql-type.tsx @@ -0,0 +1,49 @@ +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 ( + + + ! + + ); + } + + if (props.type instanceof GraphQLList) { + return ( + + [ + + ] + + ); + } + + if ( + props.type instanceof GraphQLScalarType || + props.type instanceof GraphQLEnumType + ) { + return ( + + {props.type.name} + + ); + } + + return ( + + {props.type.name} + + ); +}; diff --git a/packages/web/app/src/labaratory/components/icons.tsx b/packages/web/app/src/labaratory/components/icons.tsx new file mode 100644 index 00000000000..1aac83f3cb4 --- /dev/null +++ b/packages/web/app/src/labaratory/components/icons.tsx @@ -0,0 +1,45 @@ +import type { LucideProps } from "lucide-react"; + +export const GraphQLIcon = (props: LucideProps) => { + return ( + + + + + + + + + + ); +}; diff --git a/packages/web/app/src/labaratory/components/labaratory/builder.tsx b/packages/web/app/src/labaratory/components/labaratory/builder.tsx new file mode 100644 index 00000000000..5362ffa2eca --- /dev/null +++ b/packages/web/app/src/labaratory/components/labaratory/builder.tsx @@ -0,0 +1,617 @@ +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 '@/labaratory/components/graphql-type'; +import { useLabaratory } from '@/labaratory/components/labaratory/context'; +import { Button } from '@/labaratory/components/ui/button'; +import { Checkbox } from '@/labaratory/components/ui/checkbox'; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/labaratory/components/ui/collapsible'; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from '@/labaratory/components/ui/empty'; +import { ScrollArea, ScrollBar } from '@/labaratory/components/ui/scroll-area'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/labaratory/components/ui/tabs'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/labaratory/components/ui/tooltip'; +import type { LabaratoryOperation } from '@/labaratory/lib/operations'; +import { getOpenPaths, isArgInQuery, isPathInQuery } from '@/labaratory/lib/operations.utils'; +import { cn } from '@/labaratory/lib/utils'; + +export const BuilderArgument = (props: { + field: GraphQLArgument; + path: string[]; + isReadOnly?: boolean; + operation?: LabaratoryOperation | null; +}) => { + const { + schema, + activeOperation, + addArgToActiveOperation, + deleteArgFromActiveOperation, + activeTab, + } = useLabaratory(); + + 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 ( + + ); +}; + +export const BuilderScalarField = (props: { + field: GraphQLField; + path: string[]; + openPaths: string[]; + setOpenPaths: (openPaths: string[]) => void; + isReadOnly?: boolean; + operation?: LabaratoryOperation | null; +}) => { + const { activeOperation, addPathToActiveOperation, deletePathFromActiveOperation, activeTab } = + useLabaratory(); + + 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).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 ( + + + + + + {isOpen && ( +
+ {args.length > 0 && ( + + + + + + {args.map(arg => ( + + ))} + + + )} +
+ )} +
+
+ ); + } + + return ( + + ); +}; + +export const BuilderObjectField = (props: { + field: GraphQLField; + path: string[]; + openPaths: string[]; + setOpenPaths: (openPaths: string[]) => void; + isReadOnly?: boolean; + operation?: LabaratoryOperation | null; +}) => { + const { + schema, + activeOperation, + addPathToActiveOperation, + deletePathFromActiveOperation, + activeTab, + } = useLabaratory(); + + 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).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 ( + + + + + + {isOpen && ( +
+ {args.length > 0 && ( + + + + + + {args.map(arg => ( + + ))} + + + )} + {fields?.map(child => ( + + ))} +
+ )} +
+
+ ); +}; + +export const BuilderField = (props: { + field: GraphQLField; + path: string[]; + openPaths: string[]; + setOpenPaths: (openPaths: string[]) => void; + operation?: LabaratoryOperation | null; + isReadOnly?: boolean; +}) => { + const { schema } = useLabaratory(); + + const type = schema?.getType(props.field.type.toString().replaceAll(/\[|\]|!/g, '')); + + if ( + !type || + type instanceof GraphQLScalarType || + type instanceof GraphQLEnumType || + type instanceof GraphQLUnionType + ) { + return ( + + ); + } + + return ( + + ); +}; + +export const Builder = (props: { + operation?: LabaratoryOperation | null; + isReadOnly?: boolean; +}) => { + const { schema, activeOperation, openUpdateEndpointDialog } = useLabaratory(); + const [openPaths, setOpenPaths] = useState([]); + + 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('query'); + + return ( +
+
+ Builder +
+ + + + + Collapse all + +
+
+
+ {schema ? ( + +
+ + + Query + + + Mutation + + + Subscription + + +
+
+ +
+ + {queryFields?.map(field => ( + + ))} + + + {mutationFields?.map(field => ( + + ))} + + + {subscriptionFields?.map(field => ( + + ))} + +
+ + +
+
+
+ ) : ( + + + + + + No endpoint selected + + You haven't selected any endpoint yet. Get started by selecting an endpoint. + + + + + + + )} +
+
+ ); +}; diff --git a/packages/web/app/src/labaratory/components/labaratory/collections.tsx b/packages/web/app/src/labaratory/components/labaratory/collections.tsx new file mode 100644 index 00000000000..305814d5840 --- /dev/null +++ b/packages/web/app/src/labaratory/components/labaratory/collections.tsx @@ -0,0 +1,360 @@ +import { useMemo, useState } from 'react'; +import { + FolderIcon, + FolderOpenIcon, + FolderPlusIcon, + SearchIcon, + TrashIcon, + XIcon, +} from 'lucide-react'; +import { GraphQLIcon } from '@/labaratory/components/icons'; +import { useLabaratory } from '@/labaratory/components/labaratory/context'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/labaratory/components/ui/alert-dialog'; +import { Button } from '@/labaratory/components/ui/button'; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/labaratory/components/ui/collapsible'; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from '@/labaratory/components/ui/empty'; +import { Input } from '@/labaratory/components/ui/input'; +import { ScrollArea, ScrollBar } from '@/labaratory/components/ui/scroll-area'; +import { Tooltip, TooltipContent } from '@/labaratory/components/ui/tooltip'; +import type { + LabaratoryCollection, + LabaratoryCollectionOperation, +} from '@/labaratory/lib/collections'; +import { cn } from '@/labaratory/lib/utils'; +import { TooltipTrigger } from '@radix-ui/react-tooltip'; + +export const CollectionItem = (props: { collection: LabaratoryCollection }) => { + const { + activeOperation, + operations, + addOperation, + setActiveOperation, + deleteCollection, + deleteOperationFromCollection, + addTab, + setActiveTab, + checkPermissions, + } = useLabaratory(); + + const [isOpen, setIsOpen] = useState(false); + + return ( + + + + + + + + Are you sure you want to delete collection? + + + {props.collection.name} will be permanently deleted. All operations in this + collection will be deleted as well. + + + + Cancel + + + + + + + + Delete collection + + )} + + + + {isOpen && + props.collection.operations.map(operation => { + const isActive = activeOperation?.id === operation.id; + + return ( + + + + + + Are you sure you want to delete operation {operation.name}? + + + {operation.name} will be permanently deleted. + + + + Cancel + + + + + + + + Delete operation + + )} + + ); + })} + + + ); +}; + +export interface CollectionsSearchResultItem extends LabaratoryCollectionOperation { + parent: LabaratoryCollection; +} + +export const CollectionsSearchResult = (props: { items: CollectionsSearchResultItem[] }) => { + const { activeOperation, operations, addOperation, setActiveOperation, addTab, setActiveTab } = + useLabaratory(); + + return ( +
+ {props.items.map(operation => { + const isActive = activeOperation?.id === operation.id; + + return ( + + ); + })} +
+ ); +}; + +export const Collections = () => { + const [search, setSearch] = useState(''); + const { collections, openAddCollectionDialog, checkPermissions } = useLabaratory(); + + 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 ( +
+
+
+ Collections +
+ {checkPermissions?.('collections:create') && ( + + + + + Add collection + + )} +
+
+
+ + setSearch(e.target.value)} + /> + {search.length > 0 && ( + + )} +
+
+
+ +
+ {search.length > 0 ? ( + searchResults.length > 0 ? ( + + ) : ( + + + + + + No results found + + No collections found matching your search. + + + + ) + ) : collections.length > 0 ? ( + collections.map(item => ) + ) : ( + + + + + + No collections yet + + You haven't created any collections yet. Get started by adding your first + collection. + + + + + + + )} +
+ +
+
+
+ ); +}; diff --git a/packages/web/app/src/labaratory/components/labaratory/command.tsx b/packages/web/app/src/labaratory/components/labaratory/command.tsx new file mode 100644 index 00000000000..0d02194df0a --- /dev/null +++ b/packages/web/app/src/labaratory/components/labaratory/command.tsx @@ -0,0 +1,167 @@ +import { useEffect, useState } from 'react'; +import { FilePlus2Icon, FolderPlusIcon, PlayIcon, RefreshCcwIcon, ServerIcon } from 'lucide-react'; +import { useLabaratory } from '@/labaratory/components/labaratory/context'; +import { + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, + CommandShortcut, +} from '@/labaratory/components/ui/command'; + +export function Command(props: { open?: boolean; onOpenChange?: (open: boolean) => void }) { + const { + endpoint, + openAddCollectionDialog, + addOperation, + runActiveOperation, + fetchSchema, + openUpdateEndpointDialog, + addTab, + setActiveTab, + tabs, + preflight, + env, + } = useLabaratory(); + 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 ( + <> + { + setOpen(newOpen); + props.onOpenChange?.(newOpen); + }} + > + + + No results found. + + { + void runActiveOperation(endpoint!); + setOpen(false); + }} + > + + Run operation + ⌘↵ + + { + const newOperation = addOperation({ + name: '', + query: '', + variables: '', + headers: '', + extensions: '', + }); + const tab = addTab({ + type: 'operation', + data: newOperation, + }); + + setActiveTab(tab); + setOpen(false); + }} + > + + Add operation + + + + + { + openAddCollectionDialog?.(); + setOpen(false); + }} + > + + Add collection + + + + + { + openUpdateEndpointDialog?.(); + setOpen(false); + }} + > + + Update endpoint + + { + fetchSchema(); + setOpen(false); + }} + > + + Refetch schema + + + + + { + const tab = + tabs.find(t => t.type === 'env') ?? + addTab({ + type: 'env', + data: env ?? { variables: {} }, + }); + + setActiveTab(tab); + setOpen(false); + }} + > + + Open Environment Variables + + { + const tab = + tabs.find(t => t.type === 'preflight') ?? + addTab({ + type: 'preflight', + data: preflight ?? { script: '' }, + }); + + setActiveTab(tab); + setOpen(false); + }} + > + + Open Preflight Script + + + + + + ); +} diff --git a/packages/web/app/src/labaratory/components/labaratory/context.tsx b/packages/web/app/src/labaratory/components/labaratory/context.tsx new file mode 100644 index 00000000000..58e81f80865 --- /dev/null +++ b/packages/web/app/src/labaratory/components/labaratory/context.tsx @@ -0,0 +1,175 @@ +import { createContext, useContext } from 'react'; +import { + type LabaratoryCollection, + type LabaratoryCollectionOperation, + type LabaratoryCollectionsActions, + type LabaratoryCollectionsState, +} from '@/labaratory/lib/collections'; +import { + type LabaratoryEndpointActions, + type LabaratoryEndpointState, +} from '@/labaratory/lib/endpoint'; +import type { LabaratoryEnv, LabaratoryEnvActions, LabaratoryEnvState } from '@/labaratory/lib/env'; +import type { + LabaratoryHistory, + LabaratoryHistoryActions, + LabaratoryHistoryState, +} from '@/labaratory/lib/history'; +import { + type LabaratoryOperation, + type LabaratoryOperationsActions, + type LabaratoryOperationsState, +} from '@/labaratory/lib/operations'; +import type { + LabaratoryPreflight, + LabaratoryPreflightActions, + LabaratoryPreflightState, +} from '@/labaratory/lib/preflight'; +import type { + LabaratorySettings, + LabaratorySettingsActions, + LabaratorySettingsState, +} from '@/labaratory/lib/settings'; +import type { + LabaratoryTab, + LabaratoryTabsActions, + LabaratoryTabsState, +} from '@/labaratory/lib/tabs'; +import type { + LabaratoryTest, + LabaratoryTestActions, + LabaratoryTestState, +} from '@/labaratory/lib/tests'; + +type LabaratoryContextState = LabaratoryCollectionsState & + LabaratoryEndpointState & + LabaratoryOperationsState & + LabaratoryHistoryState & + LabaratoryTabsState & + LabaratoryPreflightState & + LabaratoryEnvState & + LabaratorySettingsState & + LabaratoryTestState & { + isFullScreen?: boolean; + }; +type LabaratoryContextActions = LabaratoryCollectionsActions & + LabaratoryEndpointActions & + LabaratoryOperationsActions & + LabaratoryHistoryActions & + LabaratoryTabsActions & + LabaratoryPreflightActions & + LabaratoryEnvActions & + LabaratorySettingsActions & + LabaratoryTestActions & { + 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 LabaratoryPermissions & string}:${keyof LabaratoryPermission & string}`, + ) => boolean; + }; + +const LabaratoryContext = createContext( + {} as LabaratoryContextState & LabaratoryContextActions, +); + +export const useLabaratory = () => { + return useContext(LabaratoryContext); +}; + +export interface LabaratoryPermission { + read?: boolean; + create?: boolean; + update?: boolean; + delete?: boolean; +} + +export interface LabaratoryPermissions { + preflight?: Partial; + collections?: Partial; + collectionsOperations?: Partial; +} + +export interface LabaratoryApi { + defaultEndpoint?: string | null; + onEndpointChange?: (endpoint: string | null) => void; + defaultCollections?: LabaratoryCollection[]; + onCollectionsChange?: (collections: LabaratoryCollection[]) => void; + onCollectionCreate?: (collection: LabaratoryCollection) => void; + onCollectionUpdate?: (collection: LabaratoryCollection) => void; + onCollectionDelete?: (collection: LabaratoryCollection) => void; + onCollectionOperationCreate?: ( + collection: LabaratoryCollection, + operation: LabaratoryCollectionOperation, + ) => void; + onCollectionOperationUpdate?: ( + collection: LabaratoryCollection, + operation: LabaratoryCollectionOperation, + ) => void; + onCollectionOperationDelete?: ( + collection: LabaratoryCollection, + operation: LabaratoryCollectionOperation, + ) => void; + defaultOperations?: LabaratoryOperation[]; + defaultActiveOperationId?: string; + onOperationsChange?: (operations: LabaratoryOperation[]) => void; + onActiveOperationIdChange?: (operationId: string) => void; + onOperationCreate?: (operation: LabaratoryOperation) => void; + onOperationUpdate?: (operation: LabaratoryOperation) => void; + onOperationDelete?: (operation: LabaratoryOperation) => void; + defaultHistory?: LabaratoryHistory[]; + onHistoryChange?: (history: LabaratoryHistory[]) => void; + onHistoryCreate?: (history: LabaratoryHistory) => void; + onHistoryUpdate?: (history: LabaratoryHistory) => void; + onHistoryDelete?: (history: LabaratoryHistory) => 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?: LabaratoryPreflight | null; + onPreflightChange?: (preflight: LabaratoryPreflight | null) => void; + defaultTabs?: LabaratoryTab[]; + onTabsChange?: (tabs: LabaratoryTab[]) => void; + defaultActiveTabId?: string | null; + onActiveTabIdChange?: (tabId: string | null) => void; + defaultEnv?: LabaratoryEnv | null; + onEnvChange?: (env: LabaratoryEnv | null) => void; + defaultSettings?: LabaratorySettings | null; + onSettingsChange?: (settings: LabaratorySettings | null) => void; + defaultTests?: LabaratoryTest[]; + onTestsChange?: (tests: LabaratoryTest[]) => void; + permissions?: LabaratoryPermissions; + checkPermissions?: ( + permission: `${keyof LabaratoryPermissions & string}:${keyof LabaratoryPermission & string}`, + ) => boolean; +} + +export type LabaratoryContextProps = LabaratoryContextState & + LabaratoryContextActions & + LabaratoryApi; + +export const LabaratoryProvider = (props: React.PropsWithChildren) => { + return ( + + {props.children} + + ); +}; diff --git a/packages/web/app/src/labaratory/components/labaratory/editor.tsx b/packages/web/app/src/labaratory/components/labaratory/editor.tsx new file mode 100644 index 00000000000..d72393810c9 --- /dev/null +++ b/packages/web/app/src/labaratory/components/labaratory/editor.tsx @@ -0,0 +1,197 @@ +import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'; +import * as monaco from 'monaco-editor'; +import { initializeMode } from 'monaco-graphql/initializeMode'; +import { useLabaratory } from '@/labaratory/components/labaratory/context'; +import MonacoEditor, { loader } from '@monaco-editor/react'; + +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 & { + uri?: monaco.Uri; + variablesUri?: monaco.Uri; + extraLibs?: string[]; + } +>((props, ref) => { + const editorRef = useRef(null); + const { introspection } = useLabaratory(); + + 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 ( +
+ { + 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()} + /> +
+ ); +}); diff --git a/packages/web/app/src/labaratory/components/labaratory/env.tsx b/packages/web/app/src/labaratory/components/labaratory/env.tsx new file mode 100644 index 00000000000..bdd2b281a6f --- /dev/null +++ b/packages/web/app/src/labaratory/components/labaratory/env.tsx @@ -0,0 +1,28 @@ +import { useLabaratory } from '@/labaratory/components/labaratory/context'; +import { Editor } from '@/labaratory/components/labaratory/editor'; + +export const Env = () => { + const { env, setEnv } = useLabaratory(); + + return ( +
+ `${key}=${value}`) + .join('\n')} + onChange={value => { + setEnv({ + variables: + Object.fromEntries(value?.split('\n').map(line => line.split('=')) ?? []) ?? {}, + }); + }} + language="dotenv" + options={{ + scrollbar: { + horizontal: 'hidden', + }, + }} + /> +
+ ); +}; diff --git a/packages/web/app/src/labaratory/components/labaratory/history-item.tsx b/packages/web/app/src/labaratory/components/labaratory/history-item.tsx new file mode 100644 index 00000000000..b387f4ad9bc --- /dev/null +++ b/packages/web/app/src/labaratory/components/labaratory/history-item.tsx @@ -0,0 +1,21 @@ +import { useMemo } from 'react'; +import { useLabaratory } from '@/labaratory/components/labaratory/context'; +import { Operation } from '@/labaratory/components/labaratory/operation'; + +export const HistoryItem = () => { + const { activeTab, history } = useLabaratory(); + + 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 ; +}; diff --git a/packages/web/app/src/labaratory/components/labaratory/history.tsx b/packages/web/app/src/labaratory/components/labaratory/history.tsx new file mode 100644 index 00000000000..c1ff03765ba --- /dev/null +++ b/packages/web/app/src/labaratory/components/labaratory/history.tsx @@ -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 { useLabaratory } from '@/labaratory/components/labaratory/context'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/labaratory/components/ui/alert-dialog'; +import { Button } from '@/labaratory/components/ui/button'; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/labaratory/components/ui/collapsible'; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from '@/labaratory/components/ui/empty'; +import { ScrollArea, ScrollBar } from '@/labaratory/components/ui/scroll-area'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/labaratory/components/ui/tooltip'; +import type { LabaratoryHistory, LabaratoryHistoryRequest } from '@/labaratory/lib/history'; +import { cn } from '@/labaratory/lib/utils'; + +export const HistoryOperationItem = (props: { historyItem: LabaratoryHistoryRequest }) => { + const { activeTab, addTab, setActiveTab, deleteHistory } = useLabaratory(); + + 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 ( + + + + + Are you sure you want to delete history? + + This history operation will be permanently deleted. + + + + Cancel + + + + + + + + Delete history + + + + ); +}; + +export const HistoryGroup = (props: { group: { date: string; items: LabaratoryHistory[] } }) => { + const { deleteHistoryByDay } = useLabaratory(); + const [isOpen, setIsOpen] = useState(false); + + return ( + + + + + + + Are you sure you want to delete history? + + All history for {props.group.date} will be permanently deleted. + + + + Cancel + + + + + + + + Delete history + + + + + {props.group.items.map(h => { + return ; + })} + + + ); +}; + +export const History = () => { + const { history, deleteAllHistory, tabs, setTabs, setActiveTab } = useLabaratory(); + + 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: LabaratoryHistory[] }[], + ); + }, [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 ( +
+
+ History +
+ + + + + + + + + + Are you sure you want to delete all history? + + + All history will be permanently deleted. + + + + Cancel + + + + + + + + Delete all + +
+
+
+ +
+ {goupedByDate.length > 0 ? ( + goupedByDate.map(group => { + return ; + }) + ) : ( + + + + + + No history yet + + You haven't run any operations yet. Get started by running your first operation. + + + + )} +
+ +
+
+
+ ); +}; diff --git a/packages/web/app/src/labaratory/components/labaratory/labaratory-mobile.tsx b/packages/web/app/src/labaratory/components/labaratory/labaratory-mobile.tsx new file mode 100644 index 00000000000..c3a0a207b00 --- /dev/null +++ b/packages/web/app/src/labaratory/components/labaratory/labaratory-mobile.tsx @@ -0,0 +1,395 @@ +// import { Command } from "@/labaratory/components/labaratory/command"; +// import { +// LabaratoryProvider, +// useLabaratory, +// type LabaratoryContextProps, +// } from "@/labaratory/components/labaratory/context"; +// import { +// Query, +// ResponseBody, +// ResponseHeaders, +// } from "@/labaratory/components/labaratory/operation"; +// import { Button } from "@/labaratory/components/ui/button"; +// import { +// DialogClose, +// DialogContent, +// DialogDescription, +// DialogFooter, +// DialogHeader, +// DialogTitle, +// } from "@/labaratory/components/ui/dialog"; +// import { Dialog } from "@/labaratory/components/ui/dialog"; +// import { Input } from "@/labaratory/components/ui/input"; +// import { +// Field, +// FieldError, +// FieldGroup, +// FieldLabel, +// } from "@/labaratory/components/ui/field"; +// import { useCollections } from "@/labaratory/lib/collections"; +// import { useEndpoint } from "@/labaratory/lib/endpoint"; +// import { useHistory, type LabaratoryHistory } from "@/labaratory/lib/history"; +// import { useOperations } from "@/labaratory/lib/operations"; +// import { cn } from "@/labaratory/lib/utils"; +// import { +// CircleCheckIcon, +// CircleXIcon, +// ClockIcon, +// FileCode2Icon, +// FileIcon, +// FileTextIcon, +// FoldersIcon, +// HistoryIcon, +// } from "lucide-react"; +// import { useCallback, useState } from "react"; +// import { useForm } from "@tanstack/react-form"; +// import * as z from "zod"; +// import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/labaratory/components/ui/tabs"; +// import { Tabs as MyTabs } from "@/labaratory/components/tabs"; +// import { Tabs as LabaratoryTabs } from "@/labaratory/components/labaratory/tabs"; +// import { Builder } from "@/labaratory/components/labaratory/builder"; +// import { Badge } from "@/labaratory/components/ui/badge"; +// import { +// Empty, +// EmptyContent, +// EmptyDescription, +// EmptyHeader, +// EmptyMedia, +// EmptyTitle, +// } from "@/labaratory/components/ui/empty"; +// const addCollectionFormSchema = z.object({ +// name: z.string().min(1, "Name is required"), +// }); + +// const LabaratoryContent = () => { +// const { activeOperation, addOperation } = useLabaratory(); + +// const [activePanel, setActivePanel] = useState< +// "collections" | "history" | null +// >("collections"); + +// const [selectedHistoryItem, setSelectedHistoryItem] = +// useState(null); + +// return ( +//
+// +// {!selectedHistoryItem ? ( +//
+// {activeOperation ? ( +// <> +// +// +//
+// +// +// Editor +// +// +// Builder +// +// +//
+// +//
+// +//
+//
+// +// { +// if ("startViewTransition" in document) { +// document.startViewTransition(() => { +// setSelectedHistoryItem(historyItem); +// }); +// } else { +// setSelectedHistoryItem(historyItem); +// } +// }} +// /> +// +//
+// +// ) : ( +// +// +// +// +// +// +// No operation selected +// +// +// You haven't selected any operation yet. Get started by +// selecting an operation or add a new one. +// +// +// +// +// +// +// )} +//
+// ) : ( +//
+// +// = 300, +// })} +// > +// {selectedHistoryItem?.status >= 200 && +// selectedHistoryItem?.status < 300 ? ( +// +// ) : ( +// +// )} +// {selectedHistoryItem?.status} +// +// +// +// {Math.round(selectedHistoryItem?.duration)}ms +// +// +// +// +// {Math.round(selectedHistoryItem?.size / 1024)}KB +// +// +//
+// ) : null +// } +// > +// +// +// +// +// +// +// +//
+// )} +//
+//
+// +//
+//
+// +//
+//
+// +//
+//
+// +// ); +// }; + +// export type LabaratoryProps = LabaratoryContextProps; + +// export const LabaratoryMobile = ( +// props: Pick< +// LabaratoryProps, +// | "defaultEndpoint" +// | "defaultCollections" +// | "onCollectionsChange" +// | "defaultOperations" +// | "onOperationsChange" +// | "defaultActiveOperationId" +// | "onActiveOperationIdChange" +// | "defaultHistory" +// | "onHistoryChange" +// > +// ) => { +// const endpointApi = useEndpoint(props); +// const collectionsApi = useCollections(props); +// const operationsApi = useOperations({ +// ...props, +// collectionsApi, +// }); +// const historyApi = useHistory(props); + +// const [isAddCollectionDialogOpen, setIsCollectionDialogOpen] = +// useState(false); + +// const openAddCollectionDialog = useCallback(() => { +// setIsCollectionDialogOpen(true); +// }, []); + +// const addCollectionForm = useForm({ +// defaultValues: { +// name: "", +// }, +// validators: { +// onSubmit: addCollectionFormSchema, +// }, +// onSubmit: ({ value }) => { +// collectionsApi.addCollection({ +// name: value.name, +// }); +// setIsCollectionDialogOpen(false); +// }, +// }); + +// return ( +//
+// +// +// +// Add collection +// +// Add a new collection of operations to your labaratory. +// +// +//
+//
{ +// e.preventDefault(); +// addCollectionForm.handleSubmit(); +// }} +// > +// +// { +// const isInvalid = +// field.state.meta.isTouched && !field.state.meta.isValid; +// return ( +// +// Name +// field.handleChange(e.target.value)} +// aria-invalid={isInvalid} +// placeholder="Enter name of the collection" +// autoComplete="off" +// /> +// {isInvalid && ( +// +// )} +// +// ); +// }} +// /> +// +//
+//
+// +// +// +// +// +// +//
+//
+ +// +// +// +//
+// ); +// }; diff --git a/packages/web/app/src/labaratory/components/labaratory/labaratory.tsx b/packages/web/app/src/labaratory/components/labaratory/labaratory.tsx new file mode 100644 index 00000000000..513ba96eed5 --- /dev/null +++ b/packages/web/app/src/labaratory/components/labaratory/labaratory.tsx @@ -0,0 +1,739 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { FileIcon, FoldersIcon, HistoryIcon, SettingsIcon } from 'lucide-react'; +import * as z from 'zod'; +import { Collections } from '@/labaratory/components/labaratory/collections'; +import { Command } from '@/labaratory/components/labaratory/command'; +import { + LabaratoryPermission, + LabaratoryPermissions, + LabaratoryProvider, + useLabaratory, + type LabaratoryApi, +} from '@/labaratory/components/labaratory/context'; +import { Env } from '@/labaratory/components/labaratory/env'; +import { History } from '@/labaratory/components/labaratory/history'; +import { HistoryItem } from '@/labaratory/components/labaratory/history-item'; +import { Operation } from '@/labaratory/components/labaratory/operation'; +import { Preflight } from '@/labaratory/components/labaratory/preflight'; +import { Settings } from '@/labaratory/components/labaratory/settings'; +import { Tabs } from '@/labaratory/components/labaratory/tabs'; +import { Button } from '@/labaratory/components/ui/button'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/labaratory/components/ui/dialog'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuTrigger, +} from '@/labaratory/components/ui/dropdown-menu'; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from '@/labaratory/components/ui/empty'; +import { Field, FieldError, FieldGroup, FieldLabel } from '@/labaratory/components/ui/field'; +import { Input } from '@/labaratory/components/ui/input'; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from '@/labaratory/components/ui/resizable'; +import { Toaster } from '@/labaratory/components/ui/sonner'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/labaratory/components/ui/tooltip'; +import { useCollections } from '@/labaratory/lib/collections'; +import { useEndpoint } from '@/labaratory/lib/endpoint'; +import { useEnv } from '@/labaratory/lib/env'; +import { useHistory } from '@/labaratory/lib/history'; +import { useOperations } from '@/labaratory/lib/operations'; +import { usePreflight } from '@/labaratory/lib/preflight'; +import { useSettings } from '@/labaratory/lib/settings'; +import { useTabs } from '@/labaratory/lib/tabs'; +import { useTests } from '@/labaratory/lib/tests'; +import { cn } from '@/labaratory/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 ( + { + if (!form.state.isSubmitted) { + void form.handleSubmit(); + } + + props.onOpenChange(open); + }} + > + + + Preflight prompt + + Enter values for the preflight script. +
{ + e.preventDefault(); + void form.handleSubmit(); + }} + > + + + {field => { + const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid; + return ( + + field.handleChange(e.target.value)} + aria-invalid={isInvalid} + placeholder={props.placeholder} + autoComplete="off" + /> + {isInvalid && } + + ); + }} + + +
+ + + +
+
+ ); +}; + +const LabaratoryContent = () => { + const { activeTab, addOperation, collections, addTab, setActiveTab, preflight, tabs, env } = + useLabaratory(); + 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 ; + case 'preflight': + return ; + case 'env': + return ; + case 'history': + return ; + case 'settings': + return ; + default: + return ( + + + + + + No operation selected + + You haven't selected any operation yet. Get started by selecting an operation or add + a new one. + + + + + + + ); + } + }, [activeTab?.type, addOperation, addTab, setActiveTab]); + + return ( +
+ +
+ + +
+ +
+
+ Collections +
+ + +
+ +
+
+ History +
+
+ + + + + + + + + + setCommandOpen(true)}> + Command Palette... + ⌘J + + + + { + const tab = + tabs.find(t => t.type === 'env') ?? + addTab({ + type: 'env', + data: env ?? { variables: {} }, + }); + + setActiveTab(tab); + }} + > + Environment Variables + + { + const tab = + tabs.find(t => t.type === 'preflight') ?? + addTab({ + type: 'preflight', + data: preflight ?? { script: '' }, + }); + + setActiveTab(tab); + }} + > + Preflight Script + + + { + const tab = + tabs.find(t => t.type === 'settings') ?? + addTab({ + type: 'settings', + data: {}, + }); + + setActiveTab(tab); + }} + > + Settings + + + + Settings + +
+
+ + + + +
+ +
+
{contentNode}
+
+
+
+ ); +}; + +export type LabaratoryProps = LabaratoryApi; + +export const Labaratory = ( + props: Pick< + LabaratoryProps, + | '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 LabaratoryPermissions & string}:${keyof LabaratoryPermission & string}`, + ) => { + const [namespace, action] = permission.split(':'); + + return ( + props.permissions?.[namespace as keyof LabaratoryPermissions]?.[ + action as keyof LabaratoryPermission + ] ?? 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(null); + + const [isFullScreen, setIsFullScreen] = useState(false); + + const goToFullScreen = useCallback(() => { + setIsFullScreen(true); + void containerRef.current?.requestFullscreen(); + }, []); + + const exitFullScreen = useCallback(() => { + setIsFullScreen(false); + void document.exitFullscreen(); + }, []); + + return ( +
+ + + + + Update endpoint + Update the endpoint of your labaratory. + +
+
{ + e.preventDefault(); + void updateEndpointForm.handleSubmit(); + }} + > + + + {field => { + const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid; + + return ( + field.handleChange(e.target.value)} + aria-invalid={isInvalid} + placeholder="Enter endpoint" + autoComplete="off" + /> + ); + }} + + +
+
+ + + + + + +
+
+ + + + + Add collection + + Add a new collection of operations to your labaratory. + + +
+
{ + e.preventDefault(); + void addCollectionForm.handleSubmit(); + }} + > + + + {field => { + const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid; + return ( + + Name + field.handleChange(e.target.value)} + aria-invalid={isInvalid} + placeholder="Enter name of the collection" + autoComplete="off" + /> + {isInvalid && } + + ); + }} + + +
+
+ + + + + + +
+
+ + + + Add test + Add a new test to your labaratory. + +
+
{ + e.preventDefault(); + void addTestForm.handleSubmit(); + }} + > + + + {field => { + const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid; + return ( + + Name + field.handleChange(e.target.value)} + aria-invalid={isInvalid} + placeholder="Enter name of the test" + autoComplete="off" + /> + {isInvalid && } + + ); + }} + + +
+
+ + + + + + +
+
+ + + + +
+ ); +}; diff --git a/packages/web/app/src/labaratory/components/labaratory/operation.tsx b/packages/web/app/src/labaratory/components/labaratory/operation.tsx new file mode 100644 index 00000000000..6bbb7d174da --- /dev/null +++ b/packages/web/app/src/labaratory/components/labaratory/operation.tsx @@ -0,0 +1,738 @@ +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 '@/labaratory/components/labaratory/builder'; +import { useLabaratory } from '@/labaratory/components/labaratory/context'; +import { Editor } from '@/labaratory/components/labaratory/editor'; +import { Tabs } from '@/labaratory/components/tabs'; +import { Badge } from '@/labaratory/components/ui/badge'; +import { Button } from '@/labaratory/components/ui/button'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/labaratory/components/ui/dialog'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, +} from '@/labaratory/components/ui/dropdown-menu'; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from '@/labaratory/components/ui/empty'; +import { Field, FieldGroup, FieldLabel } from '@/labaratory/components/ui/field'; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from '@/labaratory/components/ui/resizable'; +import { ScrollArea, ScrollBar } from '@/labaratory/components/ui/scroll-area'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/labaratory/components/ui/select'; +import { Spinner } from '@/labaratory/components/ui/spinner'; +import { Toggle } from '@/labaratory/components/ui/toggle'; +import type { + LabaratoryHistory, + LabaratoryHistoryRequest, + LabaratoryHistorySubscription, +} from '@/labaratory/lib/history'; +import type { LabaratoryOperation } from '@/labaratory/lib/operations'; +import { cn } from '@/labaratory/lib/utils'; +import { DropdownMenuTrigger } from '@radix-ui/react-dropdown-menu'; +import { useForm } from '@tanstack/react-form'; + +const Variables = (props: { operation?: LabaratoryOperation | null; isReadOnly?: boolean }) => { + const { activeOperation, updateActiveOperation } = useLabaratory(); + + const operation = useMemo(() => { + return props.operation ?? activeOperation ?? null; + }, [props.operation, activeOperation]); + + return ( + { + updateActiveOperation({ + variables: value ?? '', + }); + }} + options={{ + readOnly: props.isReadOnly, + }} + /> + ); +}; + +const Headers = (props: { operation?: LabaratoryOperation | null; isReadOnly?: boolean }) => { + const { activeOperation, updateActiveOperation } = useLabaratory(); + + const operation = useMemo(() => { + return props.operation ?? activeOperation ?? null; + }, [props.operation, activeOperation]); + + return ( + { + updateActiveOperation({ + headers: value ?? '', + }); + }} + options={{ + readOnly: props.isReadOnly, + }} + /> + ); +}; + +const Extensions = (props: { operation?: LabaratoryOperation | null; isReadOnly?: boolean }) => { + const { activeOperation, updateActiveOperation } = useLabaratory(); + + const operation = useMemo(() => { + return props.operation ?? activeOperation ?? null; + }, [props.operation, activeOperation]); + + return ( + { + updateActiveOperation({ + extensions: value ?? '', + }); + }} + options={{ + readOnly: props.isReadOnly, + }} + /> + ); +}; + +export const ResponseBody = ({ historyItem }: { historyItem?: LabaratoryHistory | null }) => { + return ( + + ); +}; + +export const ResponseHeaders = ({ historyItem }: { historyItem?: LabaratoryHistory | null }) => { + return ( + + ); +}; + +export const ResponsePreflight = ({ historyItem }: { historyItem?: LabaratoryHistory | null }) => { + return ( + +
+ {historyItem?.preflightLogs?.map(log => ( +
+ {log.createdAt}{' '} + + {log.level.toUpperCase()} + {' '} + {log.message.join(' ')} +
+ ))} +
+ +
+ ); +}; + +export const ResponseSubscription = ({ + historyItem, +}: { + historyItem?: LabaratoryHistorySubscription | null; +}) => { + const { isActiveOperationLoading } = useLabaratory(); + + return ( +
+
+ Subscription +
+ {isActiveOperationLoading ? ( + + Listening + + ) : ( + + Not listening + + )} +
+
+
+ +
+ {historyItem?.responses + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) + .map(response => { + const value = [ + `// ${response.createdAt}`, + '', + JSON.stringify(JSON.parse(response.data), null, 2), + ].join('\n'); + + const height = 20.5 * value.split('\n').length; + + return ( +
+ +
+ ); + })} +
+ +
+
+
+ ); +}; + +export const Response = ({ historyItem }: { historyItem?: LabaratoryHistoryRequest | 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 ( + + + {!isError ? ( + + ) : ( + + )} + {(historyItem as LabaratoryHistoryRequest).status} + + + + + {Math.round((historyItem as LabaratoryHistoryRequest).duration)} + ms + + + + + + {Math.round((historyItem as LabaratoryHistoryRequest).size / 1024)} + KB + + + + ) : null + } + > + + + + + + + {historyItem?.preflightLogs && historyItem?.preflightLogs.length > 0 ? ( + + + + ) : null} + + ); +}; + +const saveToCollectionFormSchema = z.object({ + collectionId: z.string().min(1, 'Collection is required'), +}); + +export const Query = (props: { + onAfterOperationRun?: (historyItem: LabaratoryHistory | null) => void; + operation?: LabaratoryOperation | null; + isReadOnly?: boolean; +}) => { + const { + endpoint, + runActiveOperation, + activeOperation, + isActiveOperationLoading, + updateActiveOperation, + collections, + addOperationToCollection, + addHistory, + stopActiveOperation, + addResponseToHistory, + isActiveOperationSubscription, + runPreflight, + addTab, + setActiveTab, + addOperation, + checkPermissions, + } = useLabaratory(); + + 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); + + 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); + + 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}?share=${value}`); + + toast.success('Operation copied to clipboard'); + }, + [operation], + ); + + return ( +
+ + + + Add collection + + Add a new collection of operations to your labaratory. + + +
+
{ + e.preventDefault(); + void saveToCollectionForm.handleSubmit(); + }} + > + + + {field => { + const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid; + + return ( + + Collection + + + ); + }} + + +
+
+ + + + + + +
+
+
+ Operation + {checkPermissions?.('collectionsOperations:create') && ( + + + {isActiveOperationSavedToCollection ? 'Saved' : 'Save'} + + )} +
+ + + + + + share({ variables: true })}> + Share with variables + + share({ variables: true, extensions: true })}> + Share with variables and extensions + + share({ variables: true, headers: true, extensions: true })} + > + Share with variables, extensions, headers + + + + {!props.isReadOnly ? ( + + ) : ( + + )} +
+
+
+ { + updateActiveOperation({ + query: value ?? '', + }); + }} + language="graphql" + theme="hive-laboratory" + options={{ + readOnly: props.isReadOnly, + }} + /> +
+
+ ); +}; + +export const Operation = (props: { + operation?: LabaratoryOperation; + historyItem?: LabaratoryHistory; +}) => { + const { activeOperation, history } = useLabaratory(); + + 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 ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + {historyItem ? ( + <> + {'responses' in historyItem ? ( + + ) : ( + + )} + + ) : ( + + + + + + No history yet + + No response available yet. Run your operation to see the response here. + + + + )} + + +
+ ); +}; diff --git a/packages/web/app/src/labaratory/components/labaratory/preflight.tsx b/packages/web/app/src/labaratory/components/labaratory/preflight.tsx new file mode 100644 index 00000000000..2e4921b7370 --- /dev/null +++ b/packages/web/app/src/labaratory/components/labaratory/preflight.tsx @@ -0,0 +1,284 @@ +import { useCallback } from 'react'; +import { HistoryIcon, PlayIcon } from 'lucide-react'; +import { useLabaratory } from '@/labaratory/components/labaratory/context'; +import { Editor } from '@/labaratory/components/labaratory/editor'; +import { Button } from '@/labaratory/components/ui/button'; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from '@/labaratory/components/ui/empty'; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from '@/labaratory/components/ui/resizable'; +import { ScrollArea, ScrollBar } from '@/labaratory/components/ui/scroll-area'; +import { runIsolatedLabScript } from '@/labaratory/lib/preflight'; +import { cn } from '@/labaratory/lib/utils'; + +export const Preflight = () => { + const { + preflight, + setLastTestResult, + setPreflight, + env, + setEnv, + openPreflightPromptModal, + checkPermissions, + } = useLabaratory(); + + 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 ( + + +
+
+ Preflight +
+ +
+
+
+ { + setPreflight({ + ...(preflight ?? { script: '' }), + script: value ?? '', + }); + }} + language="typescript" + extraLibs={[ + ` + interface Lab { + request: (endpoint: string, query: string, options?: { variables?: Record; extensions?: Record; headers?: Record }) => Promise; + environment: { + set: (key: string, value: string) => void; + get: (key: string) => string; + delete: (key: string) => void; + }; + prompt: (placeholder: string, defaultValue: string) => Promise; + 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'), + }} + /> +
+
+
+ + + {preflight?.lastTestResult?.logs && preflight?.lastTestResult?.logs.length > 0 ? ( +
+
+ Logs +
+
+ +
+ {preflight?.lastTestResult?.logs.map((log, i) => ( +
+ {log.createdAt}{' '} + + {log.level.toUpperCase()} + {' '} + {log.message.join(' ')} +
+ ))} +
+ +
+
+ ) : ( + + + + + + No logs yet + + No logs available yet. Run your preflight to see the logs here. + + + + )} + + + ); +}; diff --git a/packages/web/app/src/labaratory/components/labaratory/settings.tsx b/packages/web/app/src/labaratory/components/labaratory/settings.tsx new file mode 100644 index 00000000000..fc46e2d338d --- /dev/null +++ b/packages/web/app/src/labaratory/components/labaratory/settings.tsx @@ -0,0 +1,7 @@ +export const Settings = () => { + return ( +
+
+
+ ); +}; diff --git a/packages/web/app/src/labaratory/components/labaratory/tabs.tsx b/packages/web/app/src/labaratory/components/labaratory/tabs.tsx new file mode 100644 index 00000000000..0c13b328157 --- /dev/null +++ b/packages/web/app/src/labaratory/components/labaratory/tabs.tsx @@ -0,0 +1,466 @@ +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { capitalize } from 'lodash'; +import { + CirclePlus, + ExpandIcon, + FileIcon, + FlaskConicalIcon, + GlobeIcon, + HistoryIcon, + LockIcon, + MaximizeIcon, + MinimizeIcon, + ScrollTextIcon, + SettingsIcon, + XIcon, +} from 'lucide-react'; +import { GraphQLIcon } from '@/labaratory/components/icons'; +import { useLabaratory } from '@/labaratory/components/labaratory/context'; +import { Button } from '@/labaratory/components/ui/button'; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from '@/labaratory/components/ui/context-menu'; +import { ScrollArea, ScrollBar } from '@/labaratory/components/ui/scroll-area'; +import * as Sortable from '@/labaratory/components/ui/sortable'; +import { Spinner } from '@/labaratory/components/ui/spinner'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/labaratory/components/ui/tooltip'; +import { getOperationName, getOperationType } from '@/labaratory/lib/operations.utils'; +import type { + LabaratoryTab, + LabaratoryTabOperation, + LabaratoryTabTest, +} from '@/labaratory/lib/tabs'; +import { cn } from '@/labaratory/lib/utils'; + +export const Tab = (props: { + item: LabaratoryTab; + activeTab: LabaratoryTab | null; + setActiveTab: (tab: LabaratoryTab) => void; + isOperationLoading: (id: string) => boolean; + handleDeleteTab: (id: string) => void; + handleDeleteAllTabs: () => void; + handleDeleteOtherTabs: (excludeTabId: string) => void; + isOverlay?: boolean; +}) => { + const { history, operations, tests } = useLabaratory(); + const bypassMouseDownRef = useRef(false); + const timeoutRef = useRef | 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 LabaratoryTabOperation).id); + }, [props.item, operations]); + + const test = useMemo(() => { + if (props.item.type !== 'test') { + return null; + } + + return tests.find(t => t.id === (props.item.data as LabaratoryTabTest).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 ; + } + + if (props.item.type === 'preflight') { + return ; + } + + if (props.item.type === 'env') { + return ; + } + + if (props.item.type === 'history') { + return ( + + ); + } + + if (props.item.type === 'settings') { + return ; + } + + if (props.item.type === 'test') { + return ; + } + + return ; + }, [props.item, isError]); + + return ( + + + +
{ + 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); + }} + > +
{ + props.setActiveTab(props.item); + }} + onMouseUp={() => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + + bypassMouseDownRef.current = false; + }} + > + {tabIcon} + {tabName} + {props.isOperationLoading(props.item.id) && } + {props.item.readOnly && } + { + e.stopPropagation(); + }} + onClick={e => { + e.stopPropagation(); + props.handleDeleteTab(props.item.id); + }} + /> +
+
+
+ + + + Close + Close other + Close all + + + ); +}; + +export const Tabs = ({ className }: { className?: string }) => { + const { + tabs, + setTabs, + activeTab, + addTab, + deleteTab, + operations, + setActiveTab, + addOperation, + setOperations, + deleteOperation, + isOperationLoading, + goToFullScreen, + exitFullScreen, + isFullScreen, + } = useLabaratory(); + + 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 ( +
+
+
+ +
+ item.id} + orientation="horizontal" + > + + {tabs.map(item => { + return ( + <> + + + ); + })} + + + {activeItem => { + const tab = tabs.find(t => t.id === activeItem.value); + + if (!tab) { + return null; + } + + return ( + + ); + }} + + +
+ +
+
+ +
+
+
+ {isFullScreen ? ( + + + + + Exit full screen + + ) : ( + + + + + Go to full screen + + )} +
+
+ ); +}; diff --git a/packages/web/app/src/labaratory/components/tabs.tsx b/packages/web/app/src/labaratory/components/tabs.tsx new file mode 100644 index 00000000000..9e3f74bc759 --- /dev/null +++ b/packages/web/app/src/labaratory/components/tabs.tsx @@ -0,0 +1,68 @@ +import { Children, Fragment, useEffect, useMemo, useState } from 'react'; +import { cn } from '@/labaratory/lib/utils'; + +interface ItemProps { + label: string; + children: React.ReactNode; +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const Item = (_props: ItemProps) => { + return null; +}; + +export interface TabsProps { + children: (React.ReactElement | null)[]; + suffix?: React.ReactNode; +} + +export const Tabs = ({ children, suffix }: TabsProps) => { + const filteredChildren = useMemo(() => { + return children.filter(child => child !== null); + }, [children]); + + const [activeTab, setActiveTab] = useState( + 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 ( +
+
+
+
+ {Children.map(filteredChildren, child => ( + +
setActiveTab(child.props.label)} + > + {child.props.label} +
+
+ + ))} +
+ {suffix} +
+
{activeChild}
+
+ ); +}; + +Tabs.Item = Item; diff --git a/packages/web/app/src/labaratory/components/ui/alert-dialog.tsx b/packages/web/app/src/labaratory/components/ui/alert-dialog.tsx new file mode 100644 index 00000000000..cc7c157c902 --- /dev/null +++ b/packages/web/app/src/labaratory/components/ui/alert-dialog.tsx @@ -0,0 +1,132 @@ +import * as React from 'react'; +import { buttonVariants } from '@/labaratory/components/ui/button'; +import { cn } from '@/labaratory/lib/utils'; +import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'; + +function AlertDialog({ ...props }: React.ComponentProps) { + return ; +} + +function AlertDialogTrigger({ + ...props +}: React.ComponentProps) { + return ; +} + +function AlertDialogPortal({ ...props }: React.ComponentProps) { + return ; +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + ); +} + +function AlertDialogHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function AlertDialogFooter({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogAction({ + className, + ...props +}: React.ComponentProps) { + return ; +} + +function AlertDialogCancel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +}; diff --git a/packages/web/app/src/labaratory/components/ui/badge.tsx b/packages/web/app/src/labaratory/components/ui/badge.tsx new file mode 100644 index 00000000000..309c0967595 --- /dev/null +++ b/packages/web/app/src/labaratory/components/ui/badge.tsx @@ -0,0 +1,38 @@ +import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { cn } from '@/labaratory/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 & { asChild?: boolean }) { + const Comp = asChild ? Slot : 'span'; + + return ( + + ); +} + +export { Badge, badgeVariants }; diff --git a/packages/web/app/src/labaratory/components/ui/button-group.tsx b/packages/web/app/src/labaratory/components/ui/button-group.tsx new file mode 100644 index 00000000000..60f864a2314 --- /dev/null +++ b/packages/web/app/src/labaratory/components/ui/button-group.tsx @@ -0,0 +1,77 @@ +import { cva, type VariantProps } from 'class-variance-authority'; +import { Separator } from '@/labaratory/components/ui/separator'; +import { cn } from '@/labaratory/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) { + return ( +
+ ); +} + +function ButtonGroupText({ + className, + asChild = false, + ...props +}: React.ComponentProps<'div'> & { + asChild?: boolean; +}) { + const Comp = asChild ? Slot : 'div'; + + return ( + + ); +} + +function ButtonGroupSeparator({ + className, + orientation = 'vertical', + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { ButtonGroup, ButtonGroupSeparator, ButtonGroupText, buttonGroupVariants }; diff --git a/packages/web/app/src/labaratory/components/ui/button.tsx b/packages/web/app/src/labaratory/components/ui/button.tsx new file mode 100644 index 00000000000..ce4cf13230a --- /dev/null +++ b/packages/web/app/src/labaratory/components/ui/button.tsx @@ -0,0 +1,58 @@ +import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { cn } from '@/labaratory/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-xs 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 & { + asChild?: boolean; + }) { + const Comp = asChild ? Slot : 'button'; + + return ( + + ); +} + +// eslint-disable-next-line react-refresh/only-export-components +export { Button, buttonVariants }; diff --git a/packages/web/app/src/labaratory/components/ui/card.tsx b/packages/web/app/src/labaratory/components/ui/card.tsx new file mode 100644 index 00000000000..2f37410acbc --- /dev/null +++ b/packages/web/app/src/labaratory/components/ui/card.tsx @@ -0,0 +1,74 @@ +import * as React from 'react'; +import { cn } from '@/labaratory/lib/utils'; + +function Card({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function CardHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function CardTitle({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function CardDescription({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function CardAction({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function CardContent({ className, ...props }: React.ComponentProps<'div'>) { + return
; +} + +function CardFooter({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent }; diff --git a/packages/web/app/src/labaratory/components/ui/checkbox.tsx b/packages/web/app/src/labaratory/components/ui/checkbox.tsx new file mode 100644 index 00000000000..5937ad5eff0 --- /dev/null +++ b/packages/web/app/src/labaratory/components/ui/checkbox.tsx @@ -0,0 +1,26 @@ +import * as React from 'react'; +import { CheckIcon } from 'lucide-react'; +import { cn } from '@/labaratory/lib/utils'; +import * as CheckboxPrimitive from '@radix-ui/react-checkbox'; + +function Checkbox({ className, ...props }: React.ComponentProps) { + return ( + + + + + + ); +} + +export { Checkbox }; diff --git a/packages/web/app/src/labaratory/components/ui/collapsible.tsx b/packages/web/app/src/labaratory/components/ui/collapsible.tsx new file mode 100644 index 00000000000..77f86bedad3 --- /dev/null +++ b/packages/web/app/src/labaratory/components/ui/collapsible.tsx @@ -0,0 +1,31 @@ +import * as CollapsiblePrimitive from "@radix-ui/react-collapsible" + +function Collapsible({ + ...props +}: React.ComponentProps) { + return +} + +function CollapsibleTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CollapsibleContent({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Collapsible, CollapsibleTrigger, CollapsibleContent } diff --git a/packages/web/app/src/labaratory/components/ui/command.tsx b/packages/web/app/src/labaratory/components/ui/command.tsx new file mode 100644 index 00000000000..abeb68756dc --- /dev/null +++ b/packages/web/app/src/labaratory/components/ui/command.tsx @@ -0,0 +1,160 @@ +'use client'; + +import * as React from 'react'; +import { Command as CommandPrimitive } from 'cmdk'; +import { SearchIcon } from 'lucide-react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/labaratory/components/ui/dialog'; +import { cn } from '@/labaratory/lib/utils'; + +function Command({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function CommandDialog({ + title = 'Command Palette', + description = 'Search for a command to run...', + children, + className, + showCloseButton = true, + ...props +}: React.ComponentProps & { + title?: string; + description?: string; + className?: string; + showCloseButton?: boolean; +}) { + return ( + + + {title} + {description} + + + + {children} + + + + ); +} + +function CommandInput({ + className, + ...props +}: React.ComponentProps) { + return ( +
+ + +
+ ); +} + +function CommandList({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function CommandEmpty({ ...props }: React.ComponentProps) { + return ( + + ); +} + +function CommandGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CommandSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CommandItem({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function CommandShortcut({ className, ...props }: React.ComponentProps<'span'>) { + return ( + + ); +} + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +}; diff --git a/packages/web/app/src/labaratory/components/ui/context-menu.tsx b/packages/web/app/src/labaratory/components/ui/context-menu.tsx new file mode 100644 index 00000000000..75811ff077f --- /dev/null +++ b/packages/web/app/src/labaratory/components/ui/context-menu.tsx @@ -0,0 +1,221 @@ +import * as React from 'react'; +import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'; +import { cn } from '@/labaratory/lib/utils'; +import * as ContextMenuPrimitive from '@radix-ui/react-context-menu'; + +function ContextMenu({ ...props }: React.ComponentProps) { + return ; +} + +function ContextMenuTrigger({ + ...props +}: React.ComponentProps) { + return ; +} + +function ContextMenuGroup({ ...props }: React.ComponentProps) { + return ; +} + +function ContextMenuPortal({ ...props }: React.ComponentProps) { + return ; +} + +function ContextMenuSub({ ...props }: React.ComponentProps) { + return ; +} + +function ContextMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ; +} + +function ContextMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + {children} + + + ); +} + +function ContextMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function ContextMenuContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function ContextMenuItem({ + className, + inset, + variant = 'default', + ...props +}: React.ComponentProps & { + inset?: boolean; + variant?: 'default' | 'destructive'; +}) { + return ( + + ); +} + +function ContextMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function ContextMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function ContextMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + ); +} + +function ContextMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function ContextMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) { + return ( + + ); +} + +export { + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuItem, + ContextMenuCheckboxItem, + ContextMenuRadioItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuGroup, + ContextMenuPortal, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuRadioGroup, +}; diff --git a/packages/web/app/src/labaratory/components/ui/dialog.tsx b/packages/web/app/src/labaratory/components/ui/dialog.tsx new file mode 100644 index 00000000000..07f23c3d6c1 --- /dev/null +++ b/packages/web/app/src/labaratory/components/ui/dialog.tsx @@ -0,0 +1,126 @@ +import * as React from 'react'; +import { XIcon } from 'lucide-react'; +import { cn } from '@/labaratory/lib/utils'; +import * as DialogPrimitive from '@radix-ui/react-dialog'; + +function Dialog({ ...props }: React.ComponentProps) { + return ; +} + +function DialogTrigger({ ...props }: React.ComponentProps) { + return ; +} + +function DialogPortal({ ...props }: React.ComponentProps) { + return ; +} + +function DialogClose({ ...props }: React.ComponentProps) { + return ; +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean; +}) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ); +} + +function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function DialogTitle({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +}; diff --git a/packages/web/app/src/labaratory/components/ui/dropdown-menu.tsx b/packages/web/app/src/labaratory/components/ui/dropdown-menu.tsx new file mode 100644 index 00000000000..a47d76ed8da --- /dev/null +++ b/packages/web/app/src/labaratory/components/ui/dropdown-menu.tsx @@ -0,0 +1,225 @@ +import * as React from 'react'; +import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'; +import { cn } from '@/labaratory/lib/utils'; +import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; + +function DropdownMenu({ ...props }: React.ComponentProps) { + return ; +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return ; +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ; +} + +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function DropdownMenuGroup({ ...props }: React.ComponentProps) { + return ; +} + +function DropdownMenuItem({ + className, + inset, + variant = 'default', + ...props +}: React.ComponentProps & { + inset?: boolean; + variant?: 'default' | 'destructive'; +}) { + return ( + + ); +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ; +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + ); +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) { + return ( + + ); +} + +function DropdownMenuSub({ ...props }: React.ComponentProps) { + return ; +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + {children} + + + ); +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +}; diff --git a/packages/web/app/src/labaratory/components/ui/empty.tsx b/packages/web/app/src/labaratory/components/ui/empty.tsx new file mode 100644 index 00000000000..371c270d44a --- /dev/null +++ b/packages/web/app/src/labaratory/components/ui/empty.tsx @@ -0,0 +1,93 @@ +import { cva, type VariantProps } from 'class-variance-authority'; +import { cn } from '@/labaratory/lib/utils'; + +function Empty({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function EmptyHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +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) { + return ( +
+ ); +} + +function EmptyTitle({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function EmptyDescription({ className, ...props }: React.ComponentProps<'p'>) { + return ( +
a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4', + className, + )} + {...props} + /> + ); +} + +function EmptyContent({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +export { Empty, EmptyHeader, EmptyTitle, EmptyDescription, EmptyContent, EmptyMedia }; diff --git a/packages/web/app/src/labaratory/components/ui/field.tsx b/packages/web/app/src/labaratory/components/ui/field.tsx new file mode 100644 index 00000000000..81d2da8e769 --- /dev/null +++ b/packages/web/app/src/labaratory/components/ui/field.tsx @@ -0,0 +1,231 @@ +import { useMemo } from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { Label } from '@/labaratory/components/ui/label'; +import { Separator } from '@/labaratory/components/ui/separator'; +import { cn } from '@/labaratory/lib/utils'; + +function FieldSet({ className, ...props }: React.ComponentProps<'fieldset'>) { + return ( +
[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 ( + + ); +} + +function FieldGroup({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
[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) { + return ( +
+ ); +} + +function FieldContent({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function FieldLabel({ className, ...props }: React.ComponentProps) { + return ( +