From 878bf6834b9013ee2da6ca6fce95af30c29c67de Mon Sep 17 00:00:00 2001 From: Mykhailo Skorokhodov Date: Mon, 9 Feb 2026 21:03:47 +0100 Subject: [PATCH 1/3] Enhancement: Hive lab as lib --- package.json | 4 +- packages/libraries/laboratory/components.json | 22 + packages/libraries/laboratory/index.html | 16 + packages/libraries/laboratory/package.json | 141 + .../src/components/graphql-type.tsx | 38 + .../laboratory/src/components/icons.tsx | 45 + .../src/components/laboratory/builder.tsx | 726 +++ .../src/components/laboratory/collections.tsx | 387 ++ .../src/components/laboratory/command.tsx | 204 + .../src/components/laboratory/context.tsx | 193 + .../src/components/laboratory/editor.tsx | 214 + .../src/components/laboratory/env.tsx | 36 + .../components/laboratory/history-item.tsx | 28 + .../src/components/laboratory/history.tsx | 352 ++ .../src/components/laboratory/laboratory.tsx | 848 +++ .../src/components/laboratory/operation.tsx | 904 +++ .../src/components/laboratory/preflight.tsx | 308 + .../src/components/laboratory/settings.tsx | 94 + .../src/components/laboratory/tabs.tsx | 530 ++ .../laboratory/src/components/tabs.tsx | 73 + .../src/components/ui/alert-dialog.tsx | 153 + .../laboratory/src/components/ui/badge.tsx | 44 + .../src/components/ui/button-group.tsx | 82 + .../laboratory/src/components/ui/button.tsx | 58 + .../laboratory/src/components/ui/card.tsx | 90 + .../laboratory/src/components/ui/checkbox.tsx | 28 + .../src/components/ui/collapsible.tsx | 19 + .../laboratory/src/components/ui/command.tsx | 182 + .../src/components/ui/context-menu.tsx | 248 + .../laboratory/src/components/ui/dialog.tsx | 139 + .../src/components/ui/dropdown-menu.tsx | 253 + .../laboratory/src/components/ui/empty.tsx | 103 + .../laboratory/src/components/ui/field.tsx | 245 + .../src/components/ui/input-group.tsx | 167 + .../laboratory/src/components/ui/input.tsx | 19 + .../laboratory/src/components/ui/label.tsx | 20 + .../src/components/ui/resizable.tsx | 52 + .../src/components/ui/scroll-area.tsx | 54 + .../laboratory/src/components/ui/select.tsx | 183 + .../src/components/ui/separator.tsx | 26 + .../laboratory/src/components/ui/sonner.tsx | 35 + .../laboratory/src/components/ui/sortable.tsx | 592 ++ .../laboratory/src/components/ui/spinner.tsx | 15 + .../laboratory/src/components/ui/switch.tsx | 27 + .../laboratory/src/components/ui/tabs.tsx | 62 + .../laboratory/src/components/ui/textarea.tsx | 16 + .../laboratory/src/components/ui/toggle.tsx | 43 + .../laboratory/src/components/ui/tooltip.tsx | 57 + packages/libraries/laboratory/src/index.css | 80 + packages/libraries/laboratory/src/index.ts | 9 + .../laboratory/src/lib/collections.ts | 249 + .../laboratory/src/lib/compose-refs.ts | 62 + .../libraries/laboratory/src/lib/constants.ts | 1 + .../libraries/laboratory/src/lib/endpoint.ts | 95 + packages/libraries/laboratory/src/lib/env.ts | 34 + .../libraries/laboratory/src/lib/history.ts | 173 + .../laboratory/src/lib/operations.ts | 545 ++ .../laboratory/src/lib/operations.utils.ts | 999 ++++ .../libraries/laboratory/src/lib/plugins.ts | 88 + .../libraries/laboratory/src/lib/preflight.ts | 299 + .../libraries/laboratory/src/lib/settings.ts | 42 + packages/libraries/laboratory/src/lib/tabs.ts | 158 + .../libraries/laboratory/src/lib/tests.ts | 126 + .../libraries/laboratory/src/lib/utils.ts | 10 + packages/libraries/laboratory/src/main.tsx | 52 + .../laboratory/src/plugins/target-env.tsx | 111 + .../libraries/laboratory/tsconfig.app.json | 31 + packages/libraries/laboratory/tsconfig.json | 13 + .../libraries/laboratory/tsconfig.node.json | 26 + packages/libraries/laboratory/vite.config.ts | 51 + pnpm-lock.yaml | 5186 ++++++++++++----- 71 files changed, 15097 insertions(+), 1518 deletions(-) create mode 100644 packages/libraries/laboratory/components.json create mode 100644 packages/libraries/laboratory/index.html create mode 100644 packages/libraries/laboratory/package.json create mode 100644 packages/libraries/laboratory/src/components/graphql-type.tsx create mode 100644 packages/libraries/laboratory/src/components/icons.tsx create mode 100644 packages/libraries/laboratory/src/components/laboratory/builder.tsx create mode 100644 packages/libraries/laboratory/src/components/laboratory/collections.tsx create mode 100644 packages/libraries/laboratory/src/components/laboratory/command.tsx create mode 100644 packages/libraries/laboratory/src/components/laboratory/context.tsx create mode 100644 packages/libraries/laboratory/src/components/laboratory/editor.tsx create mode 100644 packages/libraries/laboratory/src/components/laboratory/env.tsx create mode 100644 packages/libraries/laboratory/src/components/laboratory/history-item.tsx create mode 100644 packages/libraries/laboratory/src/components/laboratory/history.tsx create mode 100644 packages/libraries/laboratory/src/components/laboratory/laboratory.tsx create mode 100644 packages/libraries/laboratory/src/components/laboratory/operation.tsx create mode 100644 packages/libraries/laboratory/src/components/laboratory/preflight.tsx create mode 100644 packages/libraries/laboratory/src/components/laboratory/settings.tsx create mode 100644 packages/libraries/laboratory/src/components/laboratory/tabs.tsx create mode 100644 packages/libraries/laboratory/src/components/tabs.tsx create mode 100644 packages/libraries/laboratory/src/components/ui/alert-dialog.tsx create mode 100644 packages/libraries/laboratory/src/components/ui/badge.tsx create mode 100644 packages/libraries/laboratory/src/components/ui/button-group.tsx create mode 100644 packages/libraries/laboratory/src/components/ui/button.tsx create mode 100644 packages/libraries/laboratory/src/components/ui/card.tsx create mode 100644 packages/libraries/laboratory/src/components/ui/checkbox.tsx create mode 100644 packages/libraries/laboratory/src/components/ui/collapsible.tsx create mode 100644 packages/libraries/laboratory/src/components/ui/command.tsx create mode 100644 packages/libraries/laboratory/src/components/ui/context-menu.tsx create mode 100644 packages/libraries/laboratory/src/components/ui/dialog.tsx create mode 100644 packages/libraries/laboratory/src/components/ui/dropdown-menu.tsx create mode 100644 packages/libraries/laboratory/src/components/ui/empty.tsx create mode 100644 packages/libraries/laboratory/src/components/ui/field.tsx create mode 100644 packages/libraries/laboratory/src/components/ui/input-group.tsx create mode 100644 packages/libraries/laboratory/src/components/ui/input.tsx create mode 100644 packages/libraries/laboratory/src/components/ui/label.tsx create mode 100644 packages/libraries/laboratory/src/components/ui/resizable.tsx create mode 100644 packages/libraries/laboratory/src/components/ui/scroll-area.tsx create mode 100644 packages/libraries/laboratory/src/components/ui/select.tsx create mode 100644 packages/libraries/laboratory/src/components/ui/separator.tsx create mode 100644 packages/libraries/laboratory/src/components/ui/sonner.tsx create mode 100644 packages/libraries/laboratory/src/components/ui/sortable.tsx create mode 100644 packages/libraries/laboratory/src/components/ui/spinner.tsx create mode 100644 packages/libraries/laboratory/src/components/ui/switch.tsx create mode 100644 packages/libraries/laboratory/src/components/ui/tabs.tsx create mode 100644 packages/libraries/laboratory/src/components/ui/textarea.tsx create mode 100644 packages/libraries/laboratory/src/components/ui/toggle.tsx create mode 100644 packages/libraries/laboratory/src/components/ui/tooltip.tsx create mode 100644 packages/libraries/laboratory/src/index.css create mode 100644 packages/libraries/laboratory/src/index.ts create mode 100644 packages/libraries/laboratory/src/lib/collections.ts create mode 100644 packages/libraries/laboratory/src/lib/compose-refs.ts create mode 100644 packages/libraries/laboratory/src/lib/constants.ts create mode 100644 packages/libraries/laboratory/src/lib/endpoint.ts create mode 100644 packages/libraries/laboratory/src/lib/env.ts create mode 100644 packages/libraries/laboratory/src/lib/history.ts create mode 100644 packages/libraries/laboratory/src/lib/operations.ts create mode 100644 packages/libraries/laboratory/src/lib/operations.utils.ts create mode 100644 packages/libraries/laboratory/src/lib/plugins.ts create mode 100644 packages/libraries/laboratory/src/lib/preflight.ts create mode 100644 packages/libraries/laboratory/src/lib/settings.ts create mode 100644 packages/libraries/laboratory/src/lib/tabs.ts create mode 100644 packages/libraries/laboratory/src/lib/tests.ts create mode 100644 packages/libraries/laboratory/src/lib/utils.ts create mode 100644 packages/libraries/laboratory/src/main.tsx create mode 100644 packages/libraries/laboratory/src/plugins/target-env.tsx create mode 100644 packages/libraries/laboratory/tsconfig.app.json create mode 100644 packages/libraries/laboratory/tsconfig.json create mode 100644 packages/libraries/laboratory/tsconfig.node.json create mode 100644 packages/libraries/laboratory/vite.config.ts diff --git a/package.json b/package.json index f551996ca35..8e041358e9c 100644 --- a/package.json +++ b/package.json @@ -138,6 +138,7 @@ "ip": "npm:neoip@2.1.0", "miniflare@3>undici": "^7.18.2", "tailwindcss": "3.4.17", + "@hive/laboratory>tailwindcss": "4.1.18", "@hive/app>tailwindcss": "4.1.18", "@tailwindcss/node>tailwindcss": "4.1.18", "@tailwindcss/vite>tailwindcss": "4.1.18", @@ -149,7 +150,8 @@ "lodash-es@4.x.x": "^4.17.23", "lodash@4.x.x": "^4.17.23", "seroval@<1.4.1": "^1.4.1", - "fast-xml-parser@<5.3.4": "^5.3.4" + "fast-xml-parser@<5.3.4": "^5.3.4", + "amqplib": "^0.8.0" }, "patchedDependencies": { "mjml-core@4.14.0": "patches/mjml-core@4.14.0.patch", diff --git a/packages/libraries/laboratory/components.json b/packages/libraries/laboratory/components.json new file mode 100644 index 00000000000..6beb616dc76 --- /dev/null +++ b/packages/libraries/laboratory/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/libraries/laboratory/index.html b/packages/libraries/laboratory/index.html new file mode 100644 index 00000000000..4ae65e503e2 --- /dev/null +++ b/packages/libraries/laboratory/index.html @@ -0,0 +1,16 @@ + + + + + + + Hive Laboratory + + + + + +
+ + + diff --git a/packages/libraries/laboratory/package.json b/packages/libraries/laboratory/package.json new file mode 100644 index 00000000000..7b280648110 --- /dev/null +++ b/packages/libraries/laboratory/package.json @@ -0,0 +1,141 @@ +{ + "name": "@hive/laboratory", + "version": "0.0.0", + "type": "module", + "main": "./dist/hive-laboratory.cjs.js", + "module": "./dist/hive-laboratory.es.js", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/hive-laboratory.es.js", + "require": "./dist/hive-laboratory.cjs.js" + }, + "./styles": "./dist/hive-laboratory.css" + }, + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "vite build", + "build:lib": "vite build --config vite.lib.config.ts && node scripts/build-lib-css.mjs && pnpm run emit-types", + "dev": "vite", + "dev:electron": "VITE_TARGET=electron concurrently \"vite\" \"wait-on http://localhost:5173 && electron .\"", + "emit-types": "tsc -p tsconfig.build.json", + "lint": "eslint .", + "preview": "vite preview" + }, + "peerDependencies": { + "@tanstack/react-form": "^1.23.8", + "date-fns": "^4.1.0", + "graphql-ws": "^6.0.6", + "lucide-react": "^0.548.0", + "lz-string": "^1.5.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "tslib": "^2.8.1", + "zod": "^4.1.12" + }, + "devDependencies": { + "@dagrejs/dagre": "^1.1.8", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@eslint/js": "^9.39.2", + "@mlc-ai/web-llm": "^0.2.80", + "@monaco-editor/react": "4.8.0-rc.2", + "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-context-menu": "^2.2.16", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-toggle": "^1.1.10", + "@radix-ui/react-tooltip": "^1.2.8", + "@rollup/plugin-commonjs": "^29.0.0", + "@rollup/plugin-node-resolve": "^16.0.3", + "@tailwindcss/cli": "^4.1.18", + "@tailwindcss/postcss": "^4.1.18", + "@tailwindcss/vite": "^4.1.18", + "@tanstack/react-form": "^1.27.7", + "@tanstack/react-router": "^1.154.13", + "@tanstack/react-router-devtools": "^1.154.13", + "@tanstack/router-plugin": "^1.154.13", + "@types/crypto-js": "^4.2.2", + "@types/lodash": "^4.17.23", + "@types/node": "^24.10.9", + "@types/react": "^19.2.9", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.2", + "@xyflow/react": "^12.10.0", + "autoprefixer": "^10.4.23", + "babel-plugin-react-compiler": "19.1.0-rc.3", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "color": "^5.0.3", + "concurrently": "^9.2.1", + "crypto-js": "^4.2.0", + "date-fns": "^4.1.0", + "esbuild": "^0.25.12", + "eslint": "^9.39.2", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.26", + "globals": "^16.5.0", + "graphql": "^16.12.0", + "graphql-ws": "^6.0.6", + "lodash": "^4.17.23", + "lucide-react": "^0.548.0", + "lz-string": "^1.5.0", + "monaco-editor": "^0.52.2", + "monaco-graphql": "^1.7.3", + "monacopilot": "^1.2.12", + "next-themes": "^0.4.6", + "postcss": "^8.5.6", + "postcss-prefixwrap": "^1.57.2", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "react-resizable-panels": "^3.0.6", + "react-shadow": "^20.6.0", + "rollup-plugin-typescript2": "^0.36.0", + "sonner": "^2.0.7", + "tailwind-merge": "^3.4.0", + "tailwindcss": "^4.1.18", + "tailwindcss-scoped-preflight": "^3.5.7", + "tslib": "^2.8.1", + "tsup": "^8.5.1", + "tw-animate-css": "^1.4.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.53.1", + "unplugin-dts": "1.0.0-beta.6", + "vite": "npm:rolldown-vite@7.1.14", + "vite-plugin-commonjs": "^0.10.4", + "vite-plugin-dts": "^4.5.4", + "vite-plugin-monaco-editor": "^1.1.0", + "wait-on": "^9.0.3", + "zod": "^4.3.6" + }, + "sideEffects": [ + "**/*.css" + ], + "build": { + "appId": "com.guild.hive.laboratory", + "productName": "Hive Laboratory", + "artifactName": "HiveLab-${version}-Do_Not_Open_(Seriously_It's_Pre-Release)-${os}-${arch}.${ext}", + "files": [ + "dist/**/*", + "electron/**/*" + ], + "directories": { + "buildResources": "assets" + } + } +} diff --git a/packages/libraries/laboratory/src/components/graphql-type.tsx b/packages/libraries/laboratory/src/components/graphql-type.tsx new file mode 100644 index 00000000000..cfb04958d5d --- /dev/null +++ b/packages/libraries/laboratory/src/components/graphql-type.tsx @@ -0,0 +1,38 @@ +import { + GraphQLEnumType, + GraphQLList, + GraphQLNonNull, + GraphQLScalarType, + type GraphQLInputType, + type GraphQLOutputType, +} from 'graphql'; + +export const GraphQLType = (props: { + type: GraphQLOutputType | GraphQLInputType; + className?: string; +}) => { + if (props.type instanceof GraphQLNonNull) { + return ( + + + ! + + ); + } + + 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/libraries/laboratory/src/components/icons.tsx b/packages/libraries/laboratory/src/components/icons.tsx new file mode 100644 index 00000000000..8bccb9b36af --- /dev/null +++ b/packages/libraries/laboratory/src/components/icons.tsx @@ -0,0 +1,45 @@ +import type { LucideProps } from 'lucide-react'; + +export const GraphQLIcon = (props: LucideProps) => { + return ( + + + + + + + + + + ); +}; diff --git a/packages/libraries/laboratory/src/components/laboratory/builder.tsx b/packages/libraries/laboratory/src/components/laboratory/builder.tsx new file mode 100644 index 00000000000..dbb99cbdb32 --- /dev/null +++ b/packages/libraries/laboratory/src/components/laboratory/builder.tsx @@ -0,0 +1,726 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + GraphQLEnumType, + GraphQLObjectType, + GraphQLScalarType, + GraphQLUnionType, + type GraphQLArgument, + type GraphQLField, +} from "graphql"; +import { throttle } from "lodash"; +import { + BoxIcon, + ChevronDownIcon, + CopyMinusIcon, + CuboidIcon, + FolderIcon, + RotateCcwIcon, +} from "lucide-react"; +import { GraphQLType } from "@/components/graphql-type"; +import { GraphQLIcon } from "@/components/icons"; +import { useLaboratory } from "@/components/laboratory/context"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@/components/ui/empty"; +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupInput, +} from "@/components/ui/input-group"; +import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import type { LaboratoryOperation } from "@/lib/operations"; +import { + getOpenPaths, + isArgInQuery, + isPathInQuery, +} from "@/lib/operations.utils"; +import { cn } from "@/lib/utils"; + +export const BuilderArgument = (props: { + field: GraphQLArgument; + path: string[]; + isReadOnly?: boolean; + operation?: LaboratoryOperation | null; +}) => { + const { + schema, + activeOperation, + addArgToActiveOperation, + deleteArgFromActiveOperation, + activeTab, + } = useLaboratory(); + + const operation = useMemo(() => { + return props.operation ?? activeOperation ?? null; + }, [props.operation, activeOperation]); + + const path = useMemo(() => { + return props.path.join("."); + }, [props.path]); + + const isInQuery = useMemo(() => { + return isArgInQuery(operation?.query ?? "", path, props.field.name); + }, [operation?.query, path, props.field.name]); + + return ( + + ); +}; + +export const BuilderScalarField = (props: { + field: GraphQLField; + path: string[]; + openPaths: string[]; + setOpenPaths: (openPaths: string[]) => void; + isReadOnly?: boolean; + operation?: LaboratoryOperation | null; +}) => { + const { + activeOperation, + addPathToActiveOperation, + deletePathFromActiveOperation, + activeTab, + } = useLaboratory(); + + const operation = useMemo(() => { + return props.operation ?? activeOperation ?? null; + }, [props.operation, activeOperation]); + + const isOpen = useMemo(() => { + return props.openPaths.includes(props.path.join(".")); + }, [props.openPaths, props.path]); + + const setIsOpen = useCallback( + (isOpen: boolean) => { + props.setOpenPaths( + isOpen + ? [...props.openPaths, props.path.join(".")] + : props.openPaths.filter((path) => path !== props.path.join(".")) + ); + }, + [props] + ); + + const path = useMemo(() => { + return props.path.join("."); + }, [props.path]); + + const isInQuery = useMemo(() => { + return isPathInQuery(operation?.query ?? "", path); + }, [operation?.query, path]); + + const args = useMemo(() => { + return (props.field as GraphQLField).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?: LaboratoryOperation | null; +}) => { + const { + schema, + activeOperation, + addPathToActiveOperation, + deletePathFromActiveOperation, + activeTab, + } = useLaboratory(); + + const operation = useMemo(() => { + return props.operation ?? activeOperation ?? null; + }, [props.operation, activeOperation]); + + const isOpen = useMemo(() => { + return props.openPaths.includes(props.path.join(".")); + }, [props.openPaths, props.path]); + + const setIsOpen = useCallback( + (isOpen: boolean) => { + props.setOpenPaths( + isOpen + ? [...props.openPaths, props.path.join(".")] + : props.openPaths.filter((path) => path !== props.path.join(".")) + ); + }, + [props] + ); + + const fields = useMemo( + () => + Object.values( + ( + schema?.getType( + props.field.type.toString().replace(/\[|\]|!/g, "") + ) as GraphQLObjectType + )?.getFields?.() ?? {} + ), + [schema, props.field.type] + ); + + const args = useMemo(() => { + return (props.field as GraphQLField).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?: LaboratoryOperation | null; + isReadOnly?: boolean; +}) => { + const { schema } = useLaboratory(); + + const type = schema?.getType( + props.field.type.toString().replace(/\[|\]|!/g, "") + ); + + if ( + !type || + type instanceof GraphQLScalarType || + type instanceof GraphQLEnumType || + type instanceof GraphQLUnionType + ) { + return ( + + ); + } + + return ( + + ); +}; + +export const Builder = (props: { + operation?: LaboratoryOperation | null; + isReadOnly?: boolean; +}) => { + const { schema, activeOperation, endpoint, setEndpoint, defaultEndpoint } = + useLaboratory(); + + const [endpointValue, setEndpointValue] = useState(endpoint ?? ""); + 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"); + + const throttleSetEndpoint = useMemo( + () => + throttle((endpoint: string) => { + setEndpoint(endpoint); + }, 1000), + [setEndpoint] + ); + + useEffect(() => { + throttleSetEndpoint(endpointValue); + }, [endpointValue, throttleSetEndpoint]); + + const restoreEndpoint = useCallback(() => { + setEndpointValue(endpoint ?? ""); + setEndpoint(defaultEndpoint ?? ""); + }, [defaultEndpoint, setEndpointValue]); + + return ( +
+
+ Builder +
+ + + + + Collapse all + +
+
+
+ + setEndpointValue(e.currentTarget.value)} + /> + + + + {defaultEndpoint && ( + + + + + + + Restore default endpoint + + + + )} + +
+
+ {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/libraries/laboratory/src/components/laboratory/collections.tsx b/packages/libraries/laboratory/src/components/laboratory/collections.tsx new file mode 100644 index 00000000000..e54c3e527c0 --- /dev/null +++ b/packages/libraries/laboratory/src/components/laboratory/collections.tsx @@ -0,0 +1,387 @@ +import { useMemo, useState } from "react"; +import { + FolderIcon, + FolderOpenIcon, + FolderPlusIcon, + SearchIcon, + TrashIcon, + XIcon, +} from "lucide-react"; +import { GraphQLIcon } from "@/components/icons"; +import { useLaboratory } from "@/components/laboratory/context"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@/components/ui/empty"; +import { Input } from "@/components/ui/input"; +import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; +import { Tooltip, TooltipContent } from "@/components/ui/tooltip"; +import type { + LaboratoryCollection, + LaboratoryCollectionOperation, +} from "@/lib/collections"; +import { cn } from "@/lib/utils"; +import { TooltipTrigger } from "@radix-ui/react-tooltip"; + +export const CollectionItem = (props: { collection: LaboratoryCollection }) => { + const { + activeOperation, + operations, + addOperation, + setActiveOperation, + deleteCollection, + deleteOperationFromCollection, + addTab, + setActiveTab, + checkPermissions, + } = useLaboratory(); + + const [isOpen, setIsOpen] = useState(false); + + return ( + + + + + + + + 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 LaboratoryCollectionOperation { + parent: LaboratoryCollection; +} + +export const CollectionsSearchResult = (props: { + items: CollectionsSearchResultItem[]; +}) => { + const { + activeOperation, + operations, + addOperation, + setActiveOperation, + addTab, + setActiveTab, + } = useLaboratory(); + + return ( +
+ {props.items.map((operation) => { + const isActive = activeOperation?.id === operation.id; + + return ( + + ); + })} +
+ ); +}; + +export const Collections = () => { + const [search, setSearch] = useState(""); + const { collections, openAddCollectionDialog, checkPermissions } = + useLaboratory(); + + const searchResults = useMemo(() => { + return collections + .reduce((acc, collection) => { + return [ + ...acc, + ...collection.operations.map((operation) => ({ + ...operation, + parent: collection, + })), + ]; + }, [] as CollectionsSearchResultItem[]) + .filter((item) => { + return item.name.toLowerCase().includes(search.toLowerCase()); + }); + }, [collections, search]); + + return ( +
+
+
+ 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/libraries/laboratory/src/components/laboratory/command.tsx b/packages/libraries/laboratory/src/components/laboratory/command.tsx new file mode 100644 index 00000000000..e1f7b2537ca --- /dev/null +++ b/packages/libraries/laboratory/src/components/laboratory/command.tsx @@ -0,0 +1,204 @@ +import { Fragment, useEffect, useState } from "react"; +import { + FilePlus2Icon, + FolderPlusIcon, + PlayIcon, + RefreshCcwIcon, + ServerIcon, +} from "lucide-react"; +import { useLaboratory } from "@/components/laboratory/context"; +import { + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, + CommandShortcut, +} from "@/components/ui/command"; + +export function Command(props: { + open?: boolean; + onOpenChange?: (open: boolean) => void; +}) { + const { + endpoint, + openAddCollectionDialog, + addOperation, + runActiveOperation, + // fetchSchema, + addTab, + setActiveTab, + tabs, + preflight, + env, + plugins, + } = useLaboratory(); + const laboratory = useLaboratory(); + const [open, setOpen] = useState(props.open ?? false); + + useEffect(() => { + setOpen(props.open ?? false); + }, [props.open]); + + useEffect(() => { + const down = (e: KeyboardEvent) => { + if (e.key === "j" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + const newOpen = !open; + setOpen(newOpen); + props.onOpenChange?.(newOpen); + } + }; + + document.addEventListener("keydown", down); + return () => document.removeEventListener("keydown", down); + }, [open, props]); + + return ( + <> + { + 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 + + + {plugins + .filter((plugin) => !!plugin.commands?.length) + .map((plugin) => ( + + + + {plugin.commands?.map((command, index) => ( + { + command.onClick(laboratory, {}); + setOpen(false); + }} + > + {typeof command.icon === "function" + ? command.icon(laboratory, {}) + : command.icon} + + {typeof command.name === "function" + ? command.name(laboratory, {}) + : command.name} + + + ))} + + + ))} + + + + ); +} diff --git a/packages/libraries/laboratory/src/components/laboratory/context.tsx b/packages/libraries/laboratory/src/components/laboratory/context.tsx new file mode 100644 index 00000000000..e848a3c83f7 --- /dev/null +++ b/packages/libraries/laboratory/src/components/laboratory/context.tsx @@ -0,0 +1,193 @@ +import { createContext, useContext } from "react"; +import { IntrospectionQuery } from "graphql"; +import { + type LaboratoryCollection, + type LaboratoryCollectionOperation, + type LaboratoryCollectionsActions, + type LaboratoryCollectionsState, +} from "@/lib/collections"; +import { + type LaboratoryEndpointActions, + type LaboratoryEndpointState, +} from "@/lib/endpoint"; +import type { + LaboratoryEnv, + LaboratoryEnvActions, + LaboratoryEnvState, +} from "@/lib/env"; +import type { + LaboratoryHistory, + LaboratoryHistoryActions, + LaboratoryHistoryState, +} from "@/lib/history"; +import { + type LaboratoryOperation, + type LaboratoryOperationsActions, + type LaboratoryOperationsState, +} from "@/lib/operations"; +import { + LaboratoryPlugin, + LaboratoryPluginsActions, + LaboratoryPluginsState, +} from "@/lib/plugins"; +import type { + LaboratoryPreflight, + LaboratoryPreflightActions, + LaboratoryPreflightState, +} from "@/lib/preflight"; +import type { + LaboratorySettings, + LaboratorySettingsActions, + LaboratorySettingsState, +} from "@/lib/settings"; +import type { + LaboratoryTab, + LaboratoryTabsActions, + LaboratoryTabsState, +} from "@/lib/tabs"; +import type { + LaboratoryTest, + LaboratoryTestActions, + LaboratoryTestState, +} from "@/lib/tests"; + +type LaboratoryContextState = LaboratoryCollectionsState & + LaboratoryEndpointState & + LaboratoryOperationsState & + LaboratoryHistoryState & + LaboratoryTabsState & + LaboratoryPreflightState & + LaboratoryEnvState & + LaboratorySettingsState & + LaboratoryPluginsState & + LaboratoryTestState & { + isFullScreen?: boolean; + }; +type LaboratoryContextActions = LaboratoryCollectionsActions & + LaboratoryEndpointActions & + LaboratoryOperationsActions & + LaboratoryHistoryActions & + LaboratoryTabsActions & + LaboratoryPreflightActions & + LaboratoryEnvActions & + LaboratorySettingsActions & + LaboratoryPluginsActions & + LaboratoryTestActions & { + openAddCollectionDialog?: () => void; + openUpdateEndpointDialog?: () => void; + openAddTestDialog?: () => void; + openPreflightPromptModal?: (props: { + placeholder: string; + defaultValue?: string; + onSubmit?: (value: string | null) => void; + }) => void; + goToFullScreen?: () => void; + exitFullScreen?: () => void; + checkPermissions?: ( + permission: `${keyof LaboratoryPermissions & string}:${keyof LaboratoryPermission & string}` + ) => boolean; + }; + +const LaboratoryContext = createContext< + LaboratoryContextState & LaboratoryContextActions +>({} as LaboratoryContextState & LaboratoryContextActions); + +export const useLaboratory = () => { + return useContext(LaboratoryContext); +}; + +export interface LaboratoryPermission { + read?: boolean; + create?: boolean; + update?: boolean; + delete?: boolean; +} + +export interface LaboratoryPermissions { + preflight?: Partial; + collections?: Partial; + collectionsOperations?: Partial; +} + +export interface LaboratoryApi { + defaultEndpoint?: string | null; + onEndpointChange?: (endpoint: string | null) => void; + defaultSchemaIntrospection?: IntrospectionQuery | null; + defaultCollections?: LaboratoryCollection[]; + onCollectionsChange?: (collections: LaboratoryCollection[]) => void; + onCollectionCreate?: (collection: LaboratoryCollection) => void; + onCollectionUpdate?: (collection: LaboratoryCollection) => void; + onCollectionDelete?: (collection: LaboratoryCollection) => void; + onCollectionOperationCreate?: ( + collection: LaboratoryCollection, + operation: LaboratoryCollectionOperation + ) => void; + onCollectionOperationUpdate?: ( + collection: LaboratoryCollection, + operation: LaboratoryCollectionOperation + ) => void; + onCollectionOperationDelete?: ( + collection: LaboratoryCollection, + operation: LaboratoryCollectionOperation + ) => void; + defaultOperations?: LaboratoryOperation[]; + defaultActiveOperationId?: string; + onOperationsChange?: (operations: LaboratoryOperation[]) => void; + onActiveOperationIdChange?: (operationId: string) => void; + onOperationCreate?: (operation: LaboratoryOperation) => void; + onOperationUpdate?: (operation: LaboratoryOperation) => void; + onOperationDelete?: (operation: LaboratoryOperation) => void; + defaultHistory?: LaboratoryHistory[]; + onHistoryChange?: (history: LaboratoryHistory[]) => void; + onHistoryCreate?: (history: LaboratoryHistory) => void; + onHistoryUpdate?: (history: LaboratoryHistory) => void; + onHistoryDelete?: (history: LaboratoryHistory) => void; + openAddCollectionDialog?: () => void; + openUpdateEndpointDialog?: () => void; + openAddTestDialog?: () => void; + openPreflightPromptModal?: (props: { + placeholder: string; + defaultValue?: string; + onSubmit?: (value: string | null) => void; + }) => void; + isFullScreen?: boolean; + goToFullScreen?: () => void; + exitFullScreen?: () => void; + defaultPreflight?: LaboratoryPreflight | null; + onPreflightChange?: (preflight: LaboratoryPreflight | null) => void; + defaultTabs?: LaboratoryTab[]; + onTabsChange?: (tabs: LaboratoryTab[]) => void; + defaultActiveTabId?: string | null; + onActiveTabIdChange?: (tabId: string | null) => void; + defaultEnv?: LaboratoryEnv | null; + onEnvChange?: (env: LaboratoryEnv | null) => void; + defaultSettings?: LaboratorySettings | null; + onSettingsChange?: (settings: LaboratorySettings | null) => void; + defaultTests?: LaboratoryTest[]; + onTestsChange?: (tests: LaboratoryTest[]) => void; + permissions?: LaboratoryPermissions; + checkPermissions?: ( + permission: `${keyof LaboratoryPermissions & string}:${keyof LaboratoryPermission & string}` + ) => boolean; + plugins?: LaboratoryPlugin>[]; + defaultPluginsState?: Record; + onPluginsStateChange?: (state: Record) => void; +} + +export type LaboratoryContextProps = LaboratoryContextState & + LaboratoryContextActions & + LaboratoryApi; + +export const LaboratoryProvider = ( + props: React.PropsWithChildren +) => { + return ( + + {props.children} + + ); +}; diff --git a/packages/libraries/laboratory/src/components/laboratory/editor.tsx b/packages/libraries/laboratory/src/components/laboratory/editor.tsx new file mode 100644 index 00000000000..21668a889d3 --- /dev/null +++ b/packages/libraries/laboratory/src/components/laboratory/editor.tsx @@ -0,0 +1,214 @@ +import { forwardRef, useEffect, useId, useImperativeHandle, useRef } from 'react'; +import color from 'color'; +import * as monaco from 'monaco-editor'; +import { initializeMode } from 'monaco-graphql/initializeMode'; +import { useLaboratory } from '@/components/laboratory/context'; +import MonacoEditor, { loader } from '@monaco-editor/react'; + +if (typeof window !== 'undefined') { + (window as Window & typeof globalThis & { monaco: typeof monaco }).monaco = monaco; +} + +loader.config({ monaco }); + +monaco.languages.register({ id: 'dotenv' }); + +const darkTheme: monaco.editor.IStandaloneThemeData = { + base: 'vs-dark', + inherit: true, + rules: [ + { token: '', foreground: 'F8F9FA', background: 'fffffe' }, + { token: 'invalid', foreground: 'cd3131' }, + { token: 'emphasis', fontStyle: 'italic' }, + { token: 'strong', fontStyle: 'bold' }, + + { token: 'variable', foreground: '001188' }, + { token: 'variable.predefined', foreground: '4864AA' }, + { token: 'constant', foreground: 'dd0000' }, + { token: 'comment', foreground: '15803d' }, + { token: 'number', foreground: 'fde68a' }, + { token: 'number.hex', foreground: '3030c0' }, + { token: 'regexp', foreground: '800000' }, + { token: 'annotation', foreground: '808080' }, + { token: 'type', foreground: 'fde68a' }, + + { token: 'delimiter', foreground: '6E757C' }, + { token: 'delimiter.html', foreground: '383838' }, + { token: 'delimiter.xml', foreground: 'facc15' }, + + { token: 'tag', foreground: '800000' }, + { token: 'tag.id.jade', foreground: '4F76AC' }, + { token: 'tag.class.jade', foreground: '4F76AC' }, + { token: 'meta.scss', foreground: '800000' }, + { token: 'metatag', foreground: 'e00000' }, + { token: 'metatag.content.html', foreground: 'FF0000' }, + { token: 'metatag.html', foreground: '808080' }, + { token: 'metatag.xml', foreground: '808080' }, + { token: 'metatag.php', fontStyle: 'bold' }, + + { token: 'key', foreground: '93c5fd' }, + { token: 'string.key.json', foreground: '93c5fd' }, + { token: 'string.value.json', foreground: 'fdba74' }, + + { token: 'attribute.name', foreground: 'FF0000' }, + { token: 'attribute.value', foreground: '34d399' }, + { token: 'attribute.value.number', foreground: 'fdba74' }, + { token: 'attribute.value.unit', foreground: 'fdba74' }, + { token: 'attribute.value.html', foreground: 'facc15' }, + { token: 'attribute.value.xml', foreground: 'facc15' }, + + { token: 'string', foreground: '2dd4bf' }, + { token: 'string.html', foreground: 'facc15' }, + { token: 'string.sql', foreground: 'FF0000' }, + { token: 'string.yaml', foreground: '34d399' }, + + { token: 'keyword', foreground: '60a5fa' }, + { token: 'keyword.json', foreground: '34d399' }, + { token: 'keyword.flow', foreground: 'AF00DB' }, + { token: 'keyword.flow.scss', foreground: 'facc15' }, + + { token: 'operator.scss', foreground: '666666' }, + { token: 'operator.sql', foreground: '778899' }, + { token: 'operator.swift', foreground: '666666' }, + { token: 'predefined.sql', foreground: 'FF00FF' }, + ], + colors: { + 'editor.foreground': '#f6f8fa', + 'editor.background': color( + `hsl(${getComputedStyle(document.body).getPropertyValue('--background')})`, + ).hex(), + '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 id = useId(); + const editorRef = useRef(null); + const { introspection, endpoint } = useLaboratory(); + + useEffect(() => { + if (introspection) { + const api = initializeMode({ + schemas: [ + { + introspectionJSON: introspection, + uri: `schema_${endpoint}.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, + }); + + api.setCompletionSettings({ + __experimental__fillLeafsOnComplete: true, + }); + } + }, [introspection, props.uri?.toString(), props.variablesUri?.toString()]); + + 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-${id}.d.ts`, + ); + } + } + }, []); + + useImperativeHandle( + ref, + () => ({ + setValue: (value: string) => { + if (editorRef.current) { + editorRef.current.setValue(value); + } + }, + }), + [], + ); + + return ( +
+ { + editorRef.current = editor; + }} + loading={null} + options={{ + ...props.options, + lineNumbers: 'on', + cursorStyle: 'line', + cursorBlinking: 'smooth', + padding: { + top: 16, + }, + fontFamily: + 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', + minimap: { + enabled: false, + }, + automaticLayout: true, + tabSize: 2, + formatOnPaste: true, + }} + defaultPath={props.uri?.toString()} + /> +
+ ); +}); diff --git a/packages/libraries/laboratory/src/components/laboratory/env.tsx b/packages/libraries/laboratory/src/components/laboratory/env.tsx new file mode 100644 index 00000000000..e14ae70e2ff --- /dev/null +++ b/packages/libraries/laboratory/src/components/laboratory/env.tsx @@ -0,0 +1,36 @@ +import { useLaboratory } from "@/components/laboratory/context"; +import { Editor } from "@/components/laboratory/editor"; + +export const Env = () => { + const { env, setEnv } = useLaboratory(); + + return ( +
+ `${key}=${value}`) + .join("\n")} + onChange={(value) => { + setEnv({ + variables: Object.fromEntries( + value + ?.split("\n") + .filter((line) => line.trim() && !line.trim().startsWith("#")) + .map((line) => { + const parts = line.split(/=(.*)/s); + + return [parts[0].trim(), (parts[1] ?? "").trim()]; + }) ?? [] + ), + }); + }} + language="dotenv" + options={{ + scrollbar: { + horizontal: "hidden", + }, + }} + /> +
+ ); +}; diff --git a/packages/libraries/laboratory/src/components/laboratory/history-item.tsx b/packages/libraries/laboratory/src/components/laboratory/history-item.tsx new file mode 100644 index 00000000000..d0139b561cb --- /dev/null +++ b/packages/libraries/laboratory/src/components/laboratory/history-item.tsx @@ -0,0 +1,28 @@ +import { useMemo } from "react"; +import { useLaboratory } from "@/components/laboratory/context"; +import { Operation } from "@/components/laboratory/operation"; +import { LaboratoryHistoryRequest } from "@/lib/history"; + +export const HistoryItem = () => { + const { activeTab, history } = useLaboratory(); + + const historyItem = useMemo(() => { + if (activeTab?.type !== "history") { + return null; + } + + return ( + history.find( + (h) => h.id === (activeTab.data as LaboratoryHistoryRequest).id + ) ?? null + ); + }, [history, activeTab]); + + if (!historyItem) { + return null; + } + + return ( + + ); +}; diff --git a/packages/libraries/laboratory/src/components/laboratory/history.tsx b/packages/libraries/laboratory/src/components/laboratory/history.tsx new file mode 100644 index 00000000000..e58e613193d --- /dev/null +++ b/packages/libraries/laboratory/src/components/laboratory/history.tsx @@ -0,0 +1,352 @@ +import { useCallback, useMemo, useState } from "react"; +import { format } from "date-fns"; +import { + ClockIcon, + FolderClockIcon, + FolderOpenIcon, + HistoryIcon, + TrashIcon, +} from "lucide-react"; +import { useLaboratory } from "@/components/laboratory/context"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@/components/ui/empty"; +import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import type { + LaboratoryHistory, + LaboratoryHistoryRequest, +} from "@/lib/history"; +import { cn } from "@/lib/utils"; + +export const HistoryOperationItem = (props: { + historyItem: LaboratoryHistoryRequest; +}) => { + const { activeTab, addTab, setActiveTab, deleteHistory } = useLaboratory(); + + const isActive = useMemo(() => { + return ( + activeTab?.type === "history" && + (activeTab.data as LaboratoryHistoryRequest).id === props.historyItem.id + ); + }, [activeTab, props.historyItem]); + + const isError = useMemo(() => { + if (!props.historyItem.status) { + return true; + } + + 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: LaboratoryHistory[] }; +}) => { + const { deleteHistoryByDay } = useLaboratory(); + 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 } = + useLaboratory(); + + const historyItems = useMemo(() => { + return history.sort( + (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ); + }, [history]); + + const goupedByDate = useMemo(() => { + return historyItems.reduce( + (acc, h) => { + const date = format(new Date(h.createdAt), "dd MMM yyyy"); + let item = acc.find((i) => i.date === date); + + if (!item) { + item = { date, items: [] }; + + acc.push(item); + } + + item.items.push(h); + + return acc; + }, + [] as { date: string; items: LaboratoryHistory[] }[] + ); + }, [historyItems]); + + const handleDeleteAllHistory = useCallback(() => { + deleteAllHistory(); + setTabs(tabs.filter((t) => t.type !== "history")); + + const newTab = tabs.find((t) => t.type !== "history"); + + if (newTab) { + setActiveTab(newTab); + } + }, [deleteAllHistory, setTabs, tabs, setActiveTab]); + + return ( +
+
+ 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/libraries/laboratory/src/components/laboratory/laboratory.tsx b/packages/libraries/laboratory/src/components/laboratory/laboratory.tsx new file mode 100644 index 00000000000..2722dce9ac7 --- /dev/null +++ b/packages/libraries/laboratory/src/components/laboratory/laboratory.tsx @@ -0,0 +1,848 @@ +import { useCallback, useMemo, useRef, useState } from "react"; +import { FileIcon, FoldersIcon, HistoryIcon, SettingsIcon } from "lucide-react"; +import * as z from "zod"; +import { Collections } from "@/components/laboratory/collections"; +import { Command } from "@/components/laboratory/command"; +import { + LaboratoryPermission, + LaboratoryPermissions, + LaboratoryProvider, + useLaboratory, + type LaboratoryApi, +} from "@/components/laboratory/context"; +import { Env } from "@/components/laboratory/env"; +import { History } from "@/components/laboratory/history"; +import { HistoryItem } from "@/components/laboratory/history-item"; +import { Operation } from "@/components/laboratory/operation"; +import { Preflight } from "@/components/laboratory/preflight"; +import { Settings } from "@/components/laboratory/settings"; +import { Tabs } from "@/components/laboratory/tabs"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@/components/ui/empty"; +import { + Field, + FieldError, + FieldGroup, + FieldLabel, +} from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "@/components/ui/resizable"; +import { Toaster } from "@/components/ui/sonner"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { useCollections } from "@/lib/collections"; +import { useEndpoint } from "@/lib/endpoint"; +import { useEnv } from "@/lib/env"; +import { useHistory } from "@/lib/history"; +import { useOperations } from "@/lib/operations"; +import { LaboratoryPluginTab, usePlugins } from "@/lib/plugins"; +import { usePreflight } from "@/lib/preflight"; +import { useSettings } from "@/lib/settings"; +import { LaboratoryTabCustom, useTabs } from "@/lib/tabs"; +import { useTests } from "@/lib/tests"; +import { cn } from "@/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 LaboratoryContent = () => { + const { + activeTab, + addOperation, + collections, + addTab, + setActiveTab, + preflight, + tabs, + env, + plugins, + pluginsState, + setPluginsState, + } = useLaboratory(); + const laboratory = useLaboratory(); + const [activePanel, setActivePanel] = useState< + "collections" | "history" | "tests" | "settings" | null + >(collections.length > 0 ? "collections" : null); + const [commandOpen, setCommandOpen] = useState(false); + + const contentNode = useMemo(() => { + switch (activeTab?.type) { + case "operation": + return ; + case "preflight": + return ; + case "env": + return ; + case "history": + return ; + case "settings": + return ; + default: { + let pluginId: string | null = null; + let customTab: LaboratoryPluginTab> | null = + null; + + for (const plugin of plugins) { + for (const tab of plugin.tabs ?? []) { + if (tab.type === activeTab?.type) { + customTab = tab; + pluginId = plugin.id; + break; + } + } + } + + if (customTab && pluginId) { + return customTab.component( + activeTab as LaboratoryTabCustom, + laboratory, + pluginsState[pluginId] ?? {}, + (state: Record) => + setPluginsState({ ...pluginsState, [pluginId]: state }) + ); + } + + 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 LaboratoryProps = LaboratoryApi; + +export const Laboratory = ( + props: Partial< + Pick< + LaboratoryProps, + | "permissions" + | "defaultEndpoint" + | "onEndpointChange" + | "defaultCollections" + | "onCollectionsChange" + | "onCollectionCreate" + | "onCollectionUpdate" + | "onCollectionDelete" + | "onCollectionOperationCreate" + | "onCollectionOperationUpdate" + | "onCollectionOperationDelete" + | "defaultOperations" + | "onOperationsChange" + | "defaultActiveOperationId" + | "onActiveOperationIdChange" + | "onOperationCreate" + | "onOperationUpdate" + | "onOperationDelete" + | "defaultHistory" + | "onHistoryChange" + | "onHistoryCreate" + | "onHistoryUpdate" + | "onHistoryDelete" + | "defaultTabs" + | "onTabsChange" + | "defaultPreflight" + | "onPreflightChange" + | "defaultEnv" + | "onEnvChange" + | "defaultActiveTabId" + | "onActiveTabIdChange" + | "defaultSettings" + | "onSettingsChange" + | "defaultTests" + | "onTestsChange" + | "plugins" + | "defaultPluginsState" + | "onPluginsStateChange" + > + > +) => { + const checkPermissions = useCallback( + ( + permission: `${keyof LaboratoryPermissions & string}:${keyof LaboratoryPermission & string}` + ) => { + const [namespace, action] = permission.split(":"); + + return ( + props.permissions?.[namespace as keyof LaboratoryPermissions]?.[ + action as keyof LaboratoryPermission + ] ?? true + ); + }, + [props.permissions] + ); + + const settingsApi = useSettings(props); + const envApi = useEnv(props); + const preflightApi = usePreflight({ + ...props, + envApi, + }); + + const pluginsApi = usePlugins(props); + 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, + settingsApi, + pluginsApi, + 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; + }) => { + setPreflightPromptModalProps({ + placeholder: props.placeholder, + defaultValue: props.defaultValue, + onSubmit: props.onSubmit, + }); + + setTimeout(() => { + setIsPreflightPromptModalOpen(true); + }, 200); + }, + [] + ); + + const containerRef = useRef(null); + + const [isFullScreen, setIsFullScreen] = useState(false); + + const goToFullScreen = useCallback(() => { + setIsFullScreen(true); + }, []); + + const exitFullScreen = useCallback(() => { + setIsFullScreen(false); + }, []); + + return ( +
+ + + + + Update endpoint + + Update the endpoint of your laboratory. + + +
+
{ + 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 laboratory. + + +
+
{ + 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 laboratory. + + +
+
{ + 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/libraries/laboratory/src/components/laboratory/operation.tsx b/packages/libraries/laboratory/src/components/laboratory/operation.tsx new file mode 100644 index 00000000000..e4a70f30086 --- /dev/null +++ b/packages/libraries/laboratory/src/components/laboratory/operation.tsx @@ -0,0 +1,904 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + BookmarkIcon, + CircleCheckIcon, + CircleXIcon, + ClockIcon, + FileTextIcon, + HistoryIcon, + MoreHorizontalIcon, + PlayIcon, + PowerIcon, + PowerOffIcon, + 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 "@/components/laboratory/builder"; +import { useLaboratory } from "@/components/laboratory/context"; +import { Editor } from "@/components/laboratory/editor"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, +} from "@/components/ui/dropdown-menu"; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@/components/ui/empty"; +import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "@/components/ui/resizable"; +import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Spinner } from "@/components/ui/spinner"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Toggle } from "@/components/ui/toggle"; +import type { + LaboratoryHistory, + LaboratoryHistoryRequest, + LaboratoryHistorySubscription, +} from "@/lib/history"; +import type { LaboratoryOperation } from "@/lib/operations"; +import { cn } from "@/lib/utils"; +import { DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu"; +import { useForm } from "@tanstack/react-form"; + +const variablesUri = monaco.Uri.file("variables.json"); + +const Variables = (props: { + operation?: LaboratoryOperation | null; + isReadOnly?: boolean; +}) => { + const { activeOperation, updateActiveOperation } = useLaboratory(); + + const operation = useMemo(() => { + return props.operation ?? activeOperation ?? null; + }, [props.operation, activeOperation]); + + return ( + { + updateActiveOperation({ + variables: value ?? "", + }); + }} + options={{ + readOnly: props.isReadOnly, + }} + /> + ); +}; + +const Headers = (props: { + operation?: LaboratoryOperation | null; + isReadOnly?: boolean; +}) => { + const { activeOperation, updateActiveOperation } = useLaboratory(); + + const operation = useMemo(() => { + return props.operation ?? activeOperation ?? null; + }, [props.operation, activeOperation]); + + return ( + { + updateActiveOperation({ + headers: value ?? "", + }); + }} + options={{ + readOnly: props.isReadOnly, + }} + /> + ); +}; + +const Extensions = (props: { + operation?: LaboratoryOperation | null; + isReadOnly?: boolean; +}) => { + const { activeOperation, updateActiveOperation } = useLaboratory(); + + const operation = useMemo(() => { + return props.operation ?? activeOperation ?? null; + }, [props.operation, activeOperation]); + + return ( + { + updateActiveOperation({ + extensions: value ?? "", + }); + }} + options={{ + readOnly: props.isReadOnly, + }} + /> + ); +}; + +export const ResponseBody = ({ + historyItem, +}: { + historyItem?: LaboratoryHistory | null; +}) => { + return ( + + ); +}; + +export const ResponseHeaders = ({ + historyItem, +}: { + historyItem?: LaboratoryHistory | null; +}) => { + return ( + + ); +}; + +export const ResponsePreflight = ({ + historyItem, +}: { + historyItem?: LaboratoryHistory | null; +}) => { + return ( + +
+ {historyItem?.preflightLogs?.map((log, i) => ( +
+ + {log.createdAt} + {" "} + + {log.level.toUpperCase()} + {" "} + {log.message.join(" ")} +
+ ))} +
+ +
+ ); +}; + +export const ResponseSubscription = ({ + historyItem, +}: { + historyItem?: LaboratoryHistorySubscription | null; +}) => { + const { isActiveOperationLoading } = useLaboratory(); + + return ( +
+
+ Subscription +
+ {isActiveOperationLoading ? ( + + Listening + + ) : ( + + Not listening + + )} +
+
+
+ +
+ {historyItem?.responses + .sort( + (a, b) => + new Date(b.createdAt).getTime() - + new Date(a.createdAt).getTime() + ) + .map((response, i) => { + const value = [ + `// ${response.createdAt}`, + "", + JSON.stringify(JSON.parse(response.data), null, 2), + ].join("\n"); + + const height = 20.5 * value.split("\n").length; + + return ( +
+ +
+ ); + })} +
+ +
+
+
+ ); +}; + +export const Response = ({ + historyItem, +}: { + historyItem?: LaboratoryHistoryRequest | null; +}) => { + const isError = useMemo(() => { + if (!historyItem) { + return false; + } + + if (!historyItem.status) { + return true; + } + + return ( + historyItem.status < 200 || + historyItem.status >= 300 || + ("response" in historyItem && JSON.parse(historyItem.response).errors) + ); + }, [historyItem]); + + return ( + + + + Response + + + Headers + + {historyItem?.preflightLogs && + historyItem?.preflightLogs.length > 0 && ( + + Preflight + + )} + {historyItem ? ( +
+ {historyItem?.status && ( + + {!isError ? ( + + ) : ( + + )} + {(historyItem as LaboratoryHistoryRequest).status} + + )} + {historyItem?.duration && ( + + + + {Math.round( + (historyItem as LaboratoryHistoryRequest).duration! + )} + ms + + + )} + {historyItem?.size && ( + + + + {Math.round( + (historyItem as LaboratoryHistoryRequest).size! / 1024 + )} + KB + + + )} +
+ ) : null} +
+ + + + + + + + + +
+ ); +}; + +const saveToCollectionFormSchema = z.object({ + collectionId: z.string().min(1, "Collection is required"), +}); + +export const Query = (props: { + onAfterOperationRun?: (historyItem: LaboratoryHistory | null) => void; + operation?: LaboratoryOperation | null; + isReadOnly?: boolean; +}) => { + const { + endpoint, + runActiveOperation, + activeOperation, + isActiveOperationLoading, + updateActiveOperation, + collections, + addOperationToCollection, + addHistory, + stopActiveOperation, + addResponseToHistory, + isActiveOperationSubscription, + runPreflight, + addTab, + setActiveTab, + addOperation, + checkPermissions, + preflight, + setPreflight, + plugins, + pluginsState, + setPluginsState, + } = useLaboratory(); + + const operation = useMemo(() => { + return props.operation ?? activeOperation ?? null; + }, [props.operation, activeOperation]); + + const handleRunOperation = useCallback(async () => { + if (!operation || !endpoint) { + return; + } + + const result = await runPreflight?.(plugins, pluginsState); + + setPluginsState(result?.pluginsState ?? {}); + + if (result?.status === "error") { + const newItemHistory = addHistory({ + headers: "{}", + operation, + preflightLogs: result?.logs ?? [], + response: `{ + "errors": [ + { + "message": "Preflight failed check logs for more details" + } + ] + }`, + createdAt: new Date().toISOString(), + } as Omit); + + props.onAfterOperationRun?.(newItemHistory); + return; + } + + if (isActiveOperationSubscription) { + const newItemHistory = addHistory({ + responses: [], + operation, + preflightLogs: result?.logs ?? [], + createdAt: new Date().toISOString(), + } as Omit); + + void runActiveOperation(endpoint, { + env: result?.env, + headers: result?.headers, + onResponse: (data) => { + addResponseToHistory(newItemHistory.id, data); + }, + }); + + props.onAfterOperationRun?.(newItemHistory); + } else { + const startTime = performance.now(); + + const response = await runActiveOperation(endpoint, { + env: result?.env, + headers: result?.headers, + }); + + 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, + pluginsState, + ]); + + useEffect(() => { + const down = (e: KeyboardEvent) => { + if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + e.stopPropagation(); + + void handleRunOperation(); + } + }; + + document.addEventListener("keydown", down, { capture: true }); + return () => + document.removeEventListener("keydown", down, { capture: true }); + }, [handleRunOperation]); + + const [isSaveToCollectionDialogOpen, setIsSaveToCollectionDialogOpen] = + useState(false); + + const saveToCollectionForm = useForm({ + defaultValues: { + collectionId: "", + }, + validators: { + onSubmit: saveToCollectionFormSchema, + }, + onSubmit: ({ value }) => { + if (!operation) { + return; + } + + addOperationToCollection(value.collectionId, { + id: operation.id ?? "", + name: operation.name ?? "", + query: operation.query ?? "", + variables: operation.variables ?? "", + headers: operation.headers ?? "", + extensions: operation.extensions ?? "", + description: "", + }); + + setIsSaveToCollectionDialogOpen(false); + }, + }); + + const openSaveToCollectionDialog = useCallback(() => { + saveToCollectionForm.reset({ + collectionId: collections[0]?.id ?? "", + }); + + setIsSaveToCollectionDialogOpen(true); + }, [saveToCollectionForm, collections]); + + const isActiveOperationSavedToCollection = useMemo(() => { + return collections.some((c) => + c.operations.some((o) => o.id === operation?.id) + ); + }, [operation?.id, collections]); + + const share = useCallback( + (options: { + variables?: boolean; + headers?: boolean; + extensions?: boolean; + }) => { + const value = compressToEncodedURIComponent( + JSON.stringify({ + n: operation?.name, + q: operation?.query, + v: options.variables ? operation?.variables : undefined, + h: options.headers ? operation?.headers : undefined, + e: options.extensions ? operation?.extensions : undefined, + }) + ); + + void navigator.clipboard.writeText( + `${window.location.origin}${window.location.pathname}?share=${value}` + ); + + toast.success("Operation copied to clipboard"); + }, + [operation] + ); + + return ( +
+ + + + Add collection + + Add a new collection of operations to your laboratory. + + +
+
{ + 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 + + + + { + setPreflight({ + ...(preflight ?? { script: "", enabled: true }), + enabled: !preflight?.enabled, + }); + }} + > + {preflight?.enabled ? ( + + ) : ( + + )} + Preflight + + {!props.isReadOnly ? ( + + ) : ( + + )} +
+
+
+ { + updateActiveOperation({ + query: value ?? "", + }); + }} + language="graphql" + theme="hive-laboratory" + options={{ + readOnly: props.isReadOnly, + }} + /> +
+
+ ); +}; + +export const Operation = (props: { + operation?: LaboratoryOperation; + historyItem?: LaboratoryHistory; +}) => { + const { activeOperation, history } = useLaboratory(); + + const operation = useMemo(() => { + return props.operation ?? activeOperation ?? null; + }, [props.operation, activeOperation]); + + const historyItem = useMemo(() => { + return ( + props.historyItem ?? + history + .filter((h) => h.operation.id === operation?.id) + .sort( + (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + )[0] ?? + null + ); + }, [history, props.historyItem, operation?.id]); + + const isReadOnly = useMemo(() => { + return !!props.historyItem; + }, [props.historyItem]); + + return ( +
+ + + + + + + + + + + + + + + + Variables + + + Headers + + + Extensions + + + + + + + + + + + + + + + + + + {historyItem ? ( + <> + {"responses" in historyItem ? ( + + ) : ( + + )} + + ) : ( + + + + + + No history yet + + No response available yet. Run your operation to see the + response here. + + + + )} + + +
+ ); +}; diff --git a/packages/libraries/laboratory/src/components/laboratory/preflight.tsx b/packages/libraries/laboratory/src/components/laboratory/preflight.tsx new file mode 100644 index 00000000000..120a55ea220 --- /dev/null +++ b/packages/libraries/laboratory/src/components/laboratory/preflight.tsx @@ -0,0 +1,308 @@ +import { useCallback } from "react"; +import { HistoryIcon, PlayIcon } from "lucide-react"; +import { useLaboratory } from "@/components/laboratory/context"; +import { Editor } from "@/components/laboratory/editor"; +import { Button } from "@/components/ui/button"; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@/components/ui/empty"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "@/components/ui/resizable"; +import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; +import { runIsolatedLabScript } from "@/lib/preflight"; +import { cn } from "@/lib/utils"; + +export const Preflight = () => { + const { + preflight, + setLastTestResult, + setPreflight, + env, + setEnv, + openPreflightPromptModal, + checkPermissions, + plugins, + pluginsState, + setPluginsState, + } = useLaboratory(); + + const run = useCallback(async () => { + if (!preflight?.script) { + return; + } + + const result = await runIsolatedLabScript( + preflight?.script ?? "", + env ?? { variables: {} }, + (placeholder, defaultValue) => { + return new Promise((resolve) => { + openPreflightPromptModal?.({ + placeholder, + defaultValue, + onSubmit: (value) => { + resolve(value); + }, + }); + }); + }, + plugins, + pluginsState + ); + + setEnv(result?.env ?? { variables: {} }); + setPluginsState(result?.pluginsState ?? {}); + setLastTestResult(result); + }, [env, setEnv, preflight, setLastTestResult, openPreflightPromptModal]); + + return ( + + +
+
+ Preflight +
+ +
+
+
+ { + setPreflight({ + ...(preflight ?? { script: "", enabled: true }), + script: value ?? "", + }); + }} + language="typescript" + extraLibs={[ + ` + interface Lab { + environment: { + set: (key: string, value: string) => void; + get: (key: string) => string; + delete: (key: string) => void; + }; + request: { + headers: Headers; + }; + prompt: (placeholder: string, defaultValue: string) => Promise; + CryptoJS: typeof CryptoJS; + plugins: { + ${plugins + .filter((plugin) => plugin.preflight?.lab?.definition) + .map((plugin) => plugin.preflight?.lab?.definition) + .join("\n")} + } + } + + 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/libraries/laboratory/src/components/laboratory/settings.tsx b/packages/libraries/laboratory/src/components/laboratory/settings.tsx new file mode 100644 index 00000000000..5ab685fe8c2 --- /dev/null +++ b/packages/libraries/laboratory/src/components/laboratory/settings.tsx @@ -0,0 +1,94 @@ +import { z } from "zod"; +import { useLaboratory } from "@/components/laboratory/context"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useForm } from "@tanstack/react-form"; + +const settingsFormSchema = z.object({ + fetch: z.object({ + credentials: z.enum(["include", "omit", "same-origin"]), + }), +}); + +export const Settings = () => { + const { settings, setSettings } = useLaboratory(); + + const form = useForm({ + defaultValues: settings, + validators: { + onSubmit: settingsFormSchema, + }, + onSubmit: ({ value }) => { + setSettings(value as typeof settings); + }, + }); + + return ( +
+
+ + + Fetch + + Configure the fetch options for the laboratory. + + + + + + {(field) => { + const isInvalid = + field.state.meta.isTouched && !field.state.meta.isValid; + + return ( + + Credentials + + + ); + }} + + + + +
+
+ ); +}; diff --git a/packages/libraries/laboratory/src/components/laboratory/tabs.tsx b/packages/libraries/laboratory/src/components/laboratory/tabs.tsx new file mode 100644 index 00000000000..11ad3abd182 --- /dev/null +++ b/packages/libraries/laboratory/src/components/laboratory/tabs.tsx @@ -0,0 +1,530 @@ +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { capitalize } from "lodash"; +import { + CirclePlus, + FileIcon, + FlaskConicalIcon, + GlobeIcon, + HistoryIcon, + LockIcon, + MaximizeIcon, + MinimizeIcon, + ScrollTextIcon, + SettingsIcon, + XIcon, +} from "lucide-react"; +import { GraphQLIcon } from "@/components/icons"; +import { useLaboratory } from "@/components/laboratory/context"; +import { Button } from "@/components/ui/button"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; +import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; +import * as Sortable from "@/components/ui/sortable"; +import { Spinner } from "@/components/ui/spinner"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { getOperationName, getOperationType } from "@/lib/operations.utils"; +import { LaboratoryPluginTab } from "@/lib/plugins"; +import type { + LaboratoryTab, + LaboratoryTabHistory, + LaboratoryTabOperation, + LaboratoryTabTest, +} from "@/lib/tabs"; +import { cn } from "@/lib/utils"; + +export const Tab = (props: { + item: LaboratoryTab; + activeTab: LaboratoryTab | null; + setActiveTab: (tab: LaboratoryTab) => void; + isOperationLoading: (id: string) => boolean; + handleDeleteTab: (id: string) => void; + handleDeleteAllTabs: () => void; + handleDeleteOtherTabs: (excludeTabId: string) => void; + isOverlay?: boolean; +}) => { + const { history, operations, tests, plugins } = useLaboratory(); + const laboratory = useLaboratory(); + 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 as LaboratoryTabHistory).id + ); + }, [history, props.item]); + + const operation = useMemo(() => { + if (props.item.type !== "operation") { + return null; + } + + return operations.find( + (o) => o.id === (props.item.data as LaboratoryTabOperation).id + ); + }, [props.item, operations]); + + const test = useMemo(() => { + if (props.item.type !== "test") { + return null; + } + + return tests.find( + (t) => t.id === (props.item.data as LaboratoryTabTest).id + ); + }, [props.item, tests]); + + const isError = useMemo(() => { + if (!historyItem) { + return false; + } + + return ( + ("status" in historyItem && historyItem.status! < 200) || + ("status" in historyItem && historyItem.status! >= 300) || + ("response" in historyItem && JSON.parse(historyItem.response).errors) + ); + }, [historyItem]); + + const closeTab = useCallback(() => { + props.handleDeleteTab(props.item.id); + }, [props]); + + const closeAllTabs = useCallback(() => { + props.handleDeleteAllTabs(); + }, [props]); + + const closeOtherTabs = useCallback(() => { + props.handleDeleteOtherTabs(props.item.id); + }, [props]); + + const tabName = useMemo(() => { + if (props.item.type === "operation") { + const name = + operation?.name || + getOperationName(operation?.query || "") || + "Untitled"; + + if (name === "Untitled") { + const type = capitalize( + getOperationType(operation?.query || "") || "query" + ); + + return name + type; + } + + return name; + } + + if (props.item.type === "history") { + const name = + historyItem?.operation.name || + getOperationName(historyItem?.operation.query || "") || + "Untitled"; + + if (name === "Untitled") { + const type = capitalize( + getOperationType(historyItem?.operation.query || "") || "query" + ); + + return name + type; + } + + return name; + } + + if (props.item.type === "preflight") { + return "Preflight"; + } + + if (props.item.type === "env") { + return "Environment Variables"; + } + + if (props.item.type === "settings") { + return "Settings"; + } + + if (props.item.type === "test") { + return test?.name || "Untitled"; + } + + let customTab: LaboratoryPluginTab> | null = null; + + for (const plugin of plugins) { + for (const tab of plugin.tabs ?? []) { + if (tab.type === props.item.type) { + customTab = tab; + break; + } + } + } + + if (customTab) { + if (typeof customTab.name === "function") { + return customTab.name(laboratory, {}); + } + + return customTab.name; + } + + 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 ; + } + + let customTab: LaboratoryPluginTab> | null = null; + + for (const plugin of plugins) { + for (const tab of plugin.tabs ?? []) { + if (tab.type === props.item.type) { + customTab = tab; + break; + } + } + } + + if (customTab) { + if (typeof customTab.icon === "function") { + return customTab.icon(laboratory, {}); + } + + return customTab.icon; + } + + 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, + } = useLaboratory(); + + const handleAddOperation = useCallback(() => { + const newOperation = addOperation({ + name: "", + query: "", + variables: "", + headers: "", + extensions: "", + }); + + const tab = addTab({ + type: "operation", + data: newOperation, + }); + + setActiveTab(tab); + }, [addOperation, addTab, setActiveTab]); + + const handleDeleteTab = useCallback( + (tabId: string) => { + const tabIndex = tabs.findIndex((t) => t.id === tabId); + + if (tabIndex === -1) { + return; + } + + const tab = tabs[tabIndex]; + + if (tab.type === "operation") { + deleteOperation((tab.data as LaboratoryTabOperation).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 as LaboratoryTabOperation).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/libraries/laboratory/src/components/tabs.tsx b/packages/libraries/laboratory/src/components/tabs.tsx new file mode 100644 index 00000000000..18d7bd48fb5 --- /dev/null +++ b/packages/libraries/laboratory/src/components/tabs.tsx @@ -0,0 +1,73 @@ +import { Children, Fragment, useEffect, useMemo, useState } from "react"; +import { cn } from "@/lib/utils"; + +interface ItemProps { + label: string; + children: React.ReactNode; +} + +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/libraries/laboratory/src/components/ui/alert-dialog.tsx b/packages/libraries/laboratory/src/components/ui/alert-dialog.tsx new file mode 100644 index 00000000000..5a3d4dc5f0f --- /dev/null +++ b/packages/libraries/laboratory/src/components/ui/alert-dialog.tsx @@ -0,0 +1,153 @@ +import { buttonVariants } from "@/components/ui/button"; +import { cn } from "@/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/libraries/laboratory/src/components/ui/badge.tsx b/packages/libraries/laboratory/src/components/ui/badge.tsx new file mode 100644 index 00000000000..f540b003443 --- /dev/null +++ b/packages/libraries/laboratory/src/components/ui/badge.tsx @@ -0,0 +1,44 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/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/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 text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/40 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/libraries/laboratory/src/components/ui/button-group.tsx b/packages/libraries/laboratory/src/components/ui/button-group.tsx new file mode 100644 index 00000000000..3d0df78c4c9 --- /dev/null +++ b/packages/libraries/laboratory/src/components/ui/button-group.tsx @@ -0,0 +1,82 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import { Separator } from "@/components/ui/separator"; +import { cn } from "@/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/libraries/laboratory/src/components/ui/button.tsx b/packages/libraries/laboratory/src/components/ui/button.tsx new file mode 100644 index 00000000000..ae72ec50a5e --- /dev/null +++ b/packages/libraries/laboratory/src/components/ui/button.tsx @@ -0,0 +1,58 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/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/40 aria-invalid:border-destructive", + { + variants: { + variant: { + default: "bg-primary !text-black hover:bg-primary/90", + destructive: + "!text-white hover:bg-destructive/90 focus-visible:ring-destructive/40 bg-destructive/60", + outline: + "border shadow-sm hover:text-accent-foreground bg-input/30 border-input hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:text-accent-foreground 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/libraries/laboratory/src/components/ui/card.tsx b/packages/libraries/laboratory/src/components/ui/card.tsx new file mode 100644 index 00000000000..d81ddbbc9e2 --- /dev/null +++ b/packages/libraries/laboratory/src/components/ui/card.tsx @@ -0,0 +1,90 @@ +import { cn } from "@/lib/utils"; + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
[data-slot=card-action]]:grid-cols-[1fr_auto]", + className + )} + {...props} + /> + ); +} + +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/libraries/laboratory/src/components/ui/checkbox.tsx b/packages/libraries/laboratory/src/components/ui/checkbox.tsx new file mode 100644 index 00000000000..6d27c697353 --- /dev/null +++ b/packages/libraries/laboratory/src/components/ui/checkbox.tsx @@ -0,0 +1,28 @@ +import { CheckIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; +import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; + +function Checkbox({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + + ); +} + +export { Checkbox }; diff --git a/packages/libraries/laboratory/src/components/ui/collapsible.tsx b/packages/libraries/laboratory/src/components/ui/collapsible.tsx new file mode 100644 index 00000000000..e935b61ea2e --- /dev/null +++ b/packages/libraries/laboratory/src/components/ui/collapsible.tsx @@ -0,0 +1,19 @@ +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/libraries/laboratory/src/components/ui/command.tsx b/packages/libraries/laboratory/src/components/ui/command.tsx new file mode 100644 index 00000000000..c562851f54f --- /dev/null +++ b/packages/libraries/laboratory/src/components/ui/command.tsx @@ -0,0 +1,182 @@ +"use client"; + +import { Command as CommandPrimitive } from "cmdk"; +import { SearchIcon } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { cn } from "@/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/libraries/laboratory/src/components/ui/context-menu.tsx b/packages/libraries/laboratory/src/components/ui/context-menu.tsx new file mode 100644 index 00000000000..a293f30a1e9 --- /dev/null +++ b/packages/libraries/laboratory/src/components/ui/context-menu.tsx @@ -0,0 +1,248 @@ +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"; +import { cn } from "@/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/libraries/laboratory/src/components/ui/dialog.tsx b/packages/libraries/laboratory/src/components/ui/dialog.tsx new file mode 100644 index 00000000000..9b0b9d68daf --- /dev/null +++ b/packages/libraries/laboratory/src/components/ui/dialog.tsx @@ -0,0 +1,139 @@ +import { XIcon } from "lucide-react"; +import { cn } from "@/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/libraries/laboratory/src/components/ui/dropdown-menu.tsx b/packages/libraries/laboratory/src/components/ui/dropdown-menu.tsx new file mode 100644 index 00000000000..1e5e1737fbb --- /dev/null +++ b/packages/libraries/laboratory/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,253 @@ +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"; +import { cn } from "@/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/libraries/laboratory/src/components/ui/empty.tsx b/packages/libraries/laboratory/src/components/ui/empty.tsx new file mode 100644 index 00000000000..e0e7e7f6c5f --- /dev/null +++ b/packages/libraries/laboratory/src/components/ui/empty.tsx @@ -0,0 +1,103 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/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/libraries/laboratory/src/components/ui/field.tsx b/packages/libraries/laboratory/src/components/ui/field.tsx new file mode 100644 index 00000000000..0875da9bee9 --- /dev/null +++ b/packages/libraries/laboratory/src/components/ui/field.tsx @@ -0,0 +1,245 @@ +import { useMemo } from "react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { Label } from "@/components/ui/label"; +import { Separator } from "@/components/ui/separator"; +import { cn } from "@/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 ( +