From edad8bd837e208a127d2005ebb8d6cf78adc9b50 Mon Sep 17 00:00:00 2001 From: Jonathan Brennan Date: Tue, 13 Oct 2026 15:02:51 -0500 Subject: [PATCH 01/11] Remove the GraphiQL tab and its route switch --- .../use-operation-collections-plugin.tsx | 539 -------------- .../app/src/pages/target-laboratory-new.tsx | 64 +- .../web/app/src/pages/target-laboratory.tsx | 695 ------------------ .../web/app/src/routes/target/laboratory.tsx | 26 +- 4 files changed, 31 insertions(+), 1293 deletions(-) delete mode 100644 packages/web/app/src/lib/hooks/laboratory/use-operation-collections-plugin.tsx delete mode 100644 packages/web/app/src/pages/target-laboratory.tsx diff --git a/packages/web/app/src/lib/hooks/laboratory/use-operation-collections-plugin.tsx b/packages/web/app/src/lib/hooks/laboratory/use-operation-collections-plugin.tsx deleted file mode 100644 index 2401cdaceca..00000000000 --- a/packages/web/app/src/lib/hooks/laboratory/use-operation-collections-plugin.tsx +++ /dev/null @@ -1,539 +0,0 @@ -import { useEffect, useState } from 'react'; -import { - BookmarkIcon, - EllipsisIcon, - FolderIcon, - FolderOpenIcon, - PlusIcon, - SquareTerminalIcon, -} from 'lucide-react'; -import { useMutation, useQuery } from 'urql'; -import { CreateCollectionModal } from '@/components/target/laboratory/create-collection-modal'; -import { DeleteCollectionModal } from '@/components/target/laboratory/delete-collection-modal'; -import { DeleteOperationModal } from '@/components/target/laboratory/delete-operation-modal'; -import { EditOperationModal } from '@/components/target/laboratory/edit-operation-modal'; -import { Link } from '@/components/ui/link'; -import { Accordion } from '@/components/ui/primitives/accordion/accordion'; -import { Button } from '@/components/ui/primitives/button/button'; -import { Menu } from '@/components/ui/primitives/floating/menu/menu'; -import { Tooltip } from '@/components/ui/primitives/floating/tooltip/tooltip'; -import { Skeleton } from '@/components/ui/primitives/skeleton/skeleton'; -import { useToast } from '@/components/ui/primitives/toast/toast'; -import { graphql } from '@/gql'; -import { useClipboard, useToggle } from '@/lib/hooks'; -import { useOperationFromQueryString } from '@/lib/hooks/laboratory/useOperationFromQueryString'; -import { cn } from '@/lib/utils'; -import { GraphiQLPlugin, useEditorContext, usePluginContext } from '@graphiql/react'; -import { getRouteApi, useRouter } from '@tanstack/react-router'; -import { useCollections } from './use-collections'; -import { useCurrentOperation } from './use-current-operation'; -import { useSyncOperationState } from './use-sync-operation-state'; - -// The accordion hands out no ref, so the operation opened from the URL is found by id instead. -const targetRoute = getRouteApi( - '/authenticated/with-header/$organizationSlug/$projectSlug/$targetSlug', -); - -const COLLECTIONS_ID = 'laboratory-collections'; - -const CreateOperationMutation = graphql(` - mutation CreateOperation( - $selector: TargetSelectorInput! - $input: CreateDocumentCollectionOperationInput! - ) { - createOperationInDocumentCollection(selector: $selector, input: $input) { - error { - message - } - ok { - operation { - id - name - } - updatedTarget { - id - documentCollections { - edges { - cursor - node { - id - operations { - edges { - node { - id - } - cursor - } - } - } - } - } - } - } - } - } -`); - -export const TargetLaboratoryPageQuery = graphql(` - query TargetLaboratoryPageQuery( - $organizationSlug: String! - $projectSlug: String! - $targetSlug: String! - ) { - target( - reference: { - bySelector: { - organizationSlug: $organizationSlug - projectSlug: $projectSlug - targetSlug: $targetSlug - } - } - ) { - id - graphqlEndpointUrl - latestSchemaVersion { - id - sdl - } - viewerCanModifyLaboratory - ...PreflightScript_TargetFragment - } - ...Laboratory_IsCDNEnabledFragment - } -`); - -export const operationCollectionsPlugin: GraphiQLPlugin = { - title: 'Operation Collections', - content: Content, - icon: function Icon() { - const pluginContext = usePluginContext(); - const active = pluginContext?.visiblePlugin === operationCollectionsPlugin; - return ; - }, -}; - -export function Content() { - const { organizationSlug, projectSlug, targetSlug } = targetRoute.useParams(); - const [query] = useQuery({ - query: TargetLaboratoryPageQuery, - variables: { - organizationSlug, - projectSlug, - targetSlug, - }, - }); - const canEdit = query.data?.target?.viewerCanModifyLaboratory === true; - const canDelete = query.data?.target?.viewerCanModifyLaboratory === true; - - const [isCollectionModalOpen, toggleCollectionModal] = useToggle(); - const { collections, fetching: loading } = useCollections({ - organizationSlug, - projectSlug, - targetSlug, - }); - const [collectionId, setCollectionId] = useState(''); - const [isDeleteCollectionModalOpen, toggleDeleteCollectionModalOpen] = useToggle(); - const [isDeleteOperationModalOpen, toggleDeleteOperationModalOpen] = useToggle(); - const [operationToDeleteId, setOperationToDeleteId] = useState(null); - const [operationToEditId, setOperationToEditId] = useState(null); - const { clearOperation, savedOperation, setSavedOperation } = useSyncOperationState({ - organizationSlug, - projectSlug, - targetSlug, - }); - const router = useRouter(); - const [accordionValue, setAccordionValue] = useState([]); - const [isScrolled, setIsScrolled] = useState(false); - const copyToClipboard = useClipboard(); - - const currentOperation = useCurrentOperation({ - organizationSlug, - projectSlug, - targetSlug, - }); - const { queryEditor, variableEditor, headerEditor, tabs, changeTab, addTab } = useEditorContext({ - nonNull: true, - }); - - const hasAllEditors = !!(queryEditor && variableEditor && headerEditor); - const queryParamsOperationId = useOperationFromQueryString(); - const activeTab = tabs.find(tab => tab.id === queryParamsOperationId); - - const isSame = - !currentOperation || - !activeTab || - (currentOperation.query === activeTab.query && - currentOperation.variables === activeTab.variables && - currentOperation.headers === (activeTab.headers || '')); - - useEffect(() => { - if (!hasAllEditors || !currentOperation) { - const searchObj = router.latestLocation.search; - const operationString = - 'operationString' in searchObj && typeof searchObj.operationString === 'string' - ? searchObj.operationString - : null; - - // We provide an operation string when navigating to the laboratory from persisted documents - // in that case we want to show that operation within this tab. - if (operationString) { - queryEditor?.setValue(operationString); - variableEditor?.setValue(''); - headerEditor?.setValue(''); - } - - return; - } - - if (queryParamsOperationId) { - const tabIndex = tabs.findIndex(tab => tab.id === queryParamsOperationId); - - if (tabIndex !== -1) { - changeTab(tabIndex); - return; - } - // Set selected operation in editors - addTab({ - id: queryParamsOperationId, - query: (savedOperation || currentOperation).query, - variables: (savedOperation || currentOperation).variables, - headers: currentOperation.headers, - title: currentOperation.name, - }); - - if (!savedOperation) { - return; - } - - const oneWeek = 7 * 24 * 60 * 60 * 1000; - if (savedOperation.updatedAt + oneWeek < Date.now()) { - clearOperation(); - return; - } - - const currentOperationUpdatedAt = new Date(currentOperation.updatedAt).getTime(); - if (savedOperation.updatedAt > currentOperationUpdatedAt) { - queryEditor.setValue(savedOperation.query); - variableEditor.setValue(savedOperation.variables); - } - } - }, [hasAllEditors, queryParamsOperationId, currentOperation]); - - useEffect(() => { - // updateActiveTabValues({ className: isSame ? '' : tabBadgeClassName }); - if (!hasAllEditors || !currentOperation || isSame) { - return; - } - setSavedOperation({ - query: queryEditor.getValue() || '', - variables: variableEditor.getValue() || '', - }); - }, [queryEditor?.getValue(), variableEditor?.getValue()]); - - const shouldShowMenu = canEdit || canDelete; - - const initialSelectedCollection = - currentOperation?.id && - collections.find(c => c.operations.edges.some(({ node }) => node.id === currentOperation.id)) - ?.id; - - const [createOperationState, createOperation] = useMutation(CreateOperationMutation); - const { toast } = useToast(); - - const addOperation = async (collectionId: string) => { - const result = await createOperation({ - input: { - collectionId, - name: 'New Operation', - query: '{\n \n}', - headers: '', - variables: '', - }, - selector: { - targetSlug, - organizationSlug, - projectSlug, - }, - }); - if (result.error) { - toast({ - variant: 'destructive', - title: "Couldn't create operation. Please try again later.", - }); - } - if (result.data?.createOperationInDocumentCollection.error) { - toast({ - variant: 'destructive', - title: result.data.createOperationInDocumentCollection.error.message, - }); - } - if (result.data?.createOperationInDocumentCollection.ok) { - void router.navigate({ - to: '/$organizationSlug/$projectSlug/$targetSlug/laboratory', - params: { - organizationSlug, - projectSlug, - targetSlug, - }, - search: { - operation: result.data.createOperationInDocumentCollection.ok.operation.id, - }, - }); - } - }; - - useEffect(() => { - if (!initialSelectedCollection || isScrolled) return; - - setAccordionValue([initialSelectedCollection]); - setTimeout(() => { - const link = document - .getElementById(COLLECTIONS_ID) - ?.querySelector(`a[href$="${queryParamsOperationId}"]`); - - if (link) { - link.scrollIntoView(); - setIsScrolled(true); - } - }, 150); - }, [initialSelectedCollection]); - - const renderedCollections = collections.map(collection => ({ - value: collection.id, - label: ( - - {accordionValue.includes(collection.id) ? ( - - ) : ( - - )} - {collection.name} - - ), - attrs: { 'data-cy': 'collection-item' }, - triggerAttrs: { 'data-cy': 'collection-item-trigger' }, - action: shouldShowMenu ? ( - - - - } - sections={[ - [ - { - label: 'Add operation', - trailingIcon: PlusIcon, - onClick: () => void addOperation(collection.id), - disabled: createOperationState.fetching, - attrs: { 'data-cy': 'add-operation-to-collection' }, - }, - ], - [ - { - label: 'Edit', - onClick: () => { - setCollectionId(collection.id); - toggleCollectionModal(); - }, - attrs: { 'data-cy': 'edit-collection' }, - }, - { - label: 'Delete', - variant: 'destructiveAction', - onClick: () => { - setCollectionId(collection.id); - toggleDeleteCollectionModalOpen(); - }, - attrs: { 'data-cy': 'delete-collection' }, - }, - ], - ]} - /> - ) : undefined, - content: ( -
- {collection.operations.edges.length ? ( - collection.operations.edges.map(({ node }) => ( -
- - - {node.name} - - - - - } - sections={[ - [ - { - label: 'Copy link to operation', - onClick: async () => { - const url = new URL(window.location.href); - await copyToClipboard(`${url.origin}${url.pathname}?operation=${node.id}`); - }, - attrs: { 'data-cy': 'copy-operation-link' }, - }, - ], - [ - canEdit && { - label: 'Edit', - onClick: () => setOperationToEditId(node.id), - attrs: { 'data-cy': 'edit-operation' }, - }, - canDelete && { - label: 'Delete', - variant: 'destructiveAction' as const, - onClick: () => { - setOperationToDeleteId(node.id); - toggleDeleteOperationModalOpen(); - }, - attrs: { 'data-cy': 'delete-operation' }, - }, - ], - ]} - /> -
- )) - ) : ( -
- -
- )} -
- ), - })); - - const target = query.data?.target; - - return ( - <> -
-
Operations
- {target?.viewerCanModifyLaboratory && ( - // The flex item needs min-w-0 for the label inside to truncate; the button fills it. - - { - if (collectionId) { - setCollectionId(''); - } - toggleCollectionModal(); - }} - > - - - New collection - - - } - content="Create a new collection of GraphQL Operations" - /> - - )} -
- {loading ? ( -
- Loading collections - {[0, 1, 2].map(index => ( - - ))} -
- ) : collections.length ? ( - - ) : ( -
-
- -
There are no collections available.
- {canEdit && ( -
- -
- )} -
-
- )} - - - {operationToDeleteId && ( - - )} - {operationToEditId && ( - setOperationToEditId(null)} - /> - )} - - ); -} diff --git a/packages/web/app/src/pages/target-laboratory-new.tsx b/packages/web/app/src/pages/target-laboratory-new.tsx index 6d097a419b1..8af3022a277 100644 --- a/packages/web/app/src/pages/target-laboratory-new.tsx +++ b/packages/web/app/src/pages/target-laboratory-new.tsx @@ -16,7 +16,6 @@ import { graphql, useFragment } from '@/gql'; import { TargetEnvPlugin } from '@/laboratory/plugins/target-env'; import { useLocalStorage, useSlugs, useToggle } from '@/lib/hooks'; import { useCurrentOperationWithFetchingState } from '@/lib/hooks/laboratory/use-current-operation'; -import { TargetLaboratoryPageQuery } from '@/lib/hooks/laboratory/use-operation-collections-plugin'; import { useOperationFromQueryString } from '@/lib/hooks/laboratory/useOperationFromQueryString'; import { useResetState } from '@/lib/hooks/use-reset-state'; import { loadHistory, saveHistory } from '@/lib/laboratory-history-storage'; @@ -695,11 +694,34 @@ function useLaboratoryState() { }; } -function LaboratoryPageContent(props: { - selectedOperationId?: string; - defaultLaboratoryTab: 'graphiql' | 'hive-laboratory'; - onLaboratoryTabChange: (tab: 'graphiql' | 'hive-laboratory') => void; -}) { +const TargetLaboratoryPageQuery = graphql(` + query TargetLaboratoryPageQuery( + $organizationSlug: String! + $projectSlug: String! + $targetSlug: String! + ) { + target( + reference: { + bySelector: { + organizationSlug: $organizationSlug + projectSlug: $projectSlug + targetSlug: $targetSlug + } + } + ) { + id + graphqlEndpointUrl + latestSchemaVersion { + id + sdl + } + viewerCanModifyLaboratory + } + ...Laboratory_IsCDNEnabledFragment + } +`); + +function LaboratoryPageContent(props: { selectedOperationId?: string }) { const { organizationSlug, projectSlug, targetSlug } = useSlugs('target'); const [query] = useQuery({ query: TargetLaboratoryPageQuery, @@ -761,26 +783,6 @@ function LaboratoryPageContent(props: {
Laboratory -
- - props.onLaboratoryTabChange(value as 'graphiql' | 'hive-laboratory') - } - options={[ - { value: 'graphiql', label: 'GraphiQL' }, - { - value: 'hive-laboratory', - label: ( - <> - Hive Laboratory - - - ), - }, - ]} - />
Explore your GraphQL schema and run queries against your GraphQL API. @@ -857,11 +859,7 @@ function LaboratoryPageContent(props: { ); } -export function TargetLaboratoryPage(props: { - selectedOperationId: string | undefined; - defaultLaboratoryTab: 'graphiql' | 'hive-laboratory'; - onLaboratoryTabChange: (tab: 'graphiql' | 'hive-laboratory') => void; -}) { +export function TargetLaboratoryPage(props: { selectedOperationId: string | undefined }) { const [isWelcomeDialogShown, setIsWelcomeDialogShown] = useLocalStorage( 'hive:laboratory:welcome-dialog-shown', 'false', @@ -892,10 +890,6 @@ export function TargetLaboratoryPage(props: { } > -

- You always can switch to the old GraphiQL based Laboratory by using the tab switcher in - the top left cornder. -

diff --git a/packages/web/app/src/pages/target-laboratory.tsx b/packages/web/app/src/pages/target-laboratory.tsx deleted file mode 100644 index 63f81666471..00000000000 --- a/packages/web/app/src/pages/target-laboratory.tsx +++ /dev/null @@ -1,695 +0,0 @@ -import { ReactElement, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { GraphiQL } from 'graphiql'; -import { buildSchema } from 'graphql'; -import { EraserIcon, MaximizeIcon, MinimizeIcon, SaveIcon, ShareIcon } from 'lucide-react'; -import { Helmet } from 'react-helmet-async'; -import { useMutation, useQuery } from 'urql'; -import { LayoutContent } from '@/components/layouts/layout-content'; -import { ConnectLabModal } from '@/components/target/laboratory/connect-lab-modal'; -import { CreateOperationModal } from '@/components/target/laboratory/create-operation-modal'; -import { useTheme } from '@/components/theme/theme-provider'; -import { DocsLink } from '@/components/ui/docs-note'; -import { Meta } from '@/components/ui/meta'; -import { Subtitle, Title } from '@/components/ui/page'; -import { Button } from '@/components/ui/primitives/button/button'; -import { Collapsible } from '@/components/ui/primitives/collapsible/collapsible'; -import { ScrollArea } from '@/components/ui/primitives/scroll-area/scroll-area'; -import { useToast } from '@/components/ui/primitives/toast/toast'; -import { QueryError } from '@/components/ui/query-error'; -import { graphql } from '@/gql'; -import { useClipboard, useSlugs, useToggle } from '@/lib/hooks'; -import { useCollections } from '@/lib/hooks/laboratory/use-collections'; -import { useCurrentOperation } from '@/lib/hooks/laboratory/use-current-operation'; -import { - operationCollectionsPlugin, - TargetLaboratoryPageQuery, -} from '@/lib/hooks/laboratory/use-operation-collections-plugin'; -import { useSyncOperationState } from '@/lib/hooks/laboratory/use-sync-operation-state'; -import { useOperationFromQueryString } from '@/lib/hooks/laboratory/useOperationFromQueryString'; -import { useResetState } from '@/lib/hooks/use-reset-state'; -import { - LogLine, - LogRecord, - preflightPlugin, - PreflightProvider, - PreflightResultData, - usePreflight, -} from '@/lib/preflight/graphiql-plugin'; -import { cn } from '@/lib/utils'; -import { explorerPlugin } from '@graphiql/plugin-explorer'; -import { - UnStyledButton as GraphiQLButton, - GraphiQLProviderProps, - Tooltip as GraphiQLTooltip, - useEditorContext, -} from '@graphiql/react'; -import { createGraphiQLFetcher, Fetcher, isAsyncIterable } from '@graphiql/toolkit'; -import { Repeater } from '@repeaterjs/repeater'; -import { Link as RouterLink, useRouter } from '@tanstack/react-router'; -import 'graphiql/style.css'; -import '@graphiql/plugin-explorer/style.css'; -import { Menu } from '@/components/ui/primitives/floating/menu/menu'; -import { ToggleGroup } from '@/components/ui/primitives/toggle-group/toggle-group'; -import { PromptManager, PromptProvider } from '@/components/ui/prompt'; -import { Kit } from '@/lib/kit'; - -const explorer = explorerPlugin(); - -// Declare outside components, otherwise while clicking on field in explorer operationCollectionsPlugin will be open -const plugins = [explorer, operationCollectionsPlugin, preflightPlugin]; - -function Share(): ReactElement | null { - const label = 'Share query'; - const copyToClipboard = useClipboard(); - const operationFromQueryString = useOperationFromQueryString(); - - if (!operationFromQueryString) return null; - - return ( - - { - await copyToClipboard(window.location.href); - }} - > - - - - ); -} - -const UpdateOperationMutation = graphql(` - mutation UpdateOperation( - $selector: TargetSelectorInput! - $input: UpdateDocumentCollectionOperationInput! - ) { - updateOperationInDocumentCollection(selector: $selector, input: $input) { - error { - message - } - ok { - operation { - id - name - query - variables - headers - } - } - } - } -`); - -function Save(): ReactElement { - const { organizationSlug, projectSlug, targetSlug } = useSlugs('target'); - const router = useRouter(); - const [operationModalOpen, toggleOperationModal] = useToggle(); - const { collections } = useCollections({ - organizationSlug, - projectSlug, - targetSlug, - }); - const { toast } = useToast(); - const currentOperation = useCurrentOperation({ - organizationSlug, - projectSlug, - targetSlug, - }); - const [, mutateUpdate] = useMutation(UpdateOperationMutation); - const { queryEditor, variableEditor, headerEditor, updateActiveTabValues } = useEditorContext()!; - const { clearOperation } = useSyncOperationState({ - organizationSlug, - projectSlug, - targetSlug, - }); - const operationFromQueryString = useOperationFromQueryString(); - - const onSaveSuccess = useCallback( - ({ id, name }: { id: string; name: string }) => { - if (id) { - if (!operationFromQueryString) { - updateActiveTabValues({ id, title: name }); - } - void router.navigate({ - to: '/$organizationSlug/$projectSlug/$targetSlug/laboratory', - params: { - organizationSlug, - projectSlug, - targetSlug, - }, - search: { operation: id }, - }); - } - clearOperation(); - }, - [clearOperation, router], - ); - const isSame = - !!currentOperation && - currentOperation.query === queryEditor?.getValue() && - currentOperation.variables === variableEditor?.getValue() && - currentOperation.headers === headerEditor?.getValue(); - - const label = 'Save operation'; - - return ( - <> - ( - // A function, not an element: `GraphiQLTooltip` forwards nothing, so it cannot carry - // the trigger props down. Applying them to the button lets the tooltip wrap it. - - - - - - )} - sections={[ - [ - !isSame && - currentOperation && { - label: 'Discard changes', - variant: 'destructiveAction' as const, - onClick: () => { - queryEditor?.setValue(currentOperation.query); - clearOperation(); - }, - }, - ], - [ - { - label: 'Save', - disabled: isSame || !currentOperation, - onClick: async () => { - if (!currentOperation || isSame) { - return; - } - const { error, data } = await mutateUpdate({ - selector: { - targetSlug, - organizationSlug, - projectSlug, - }, - input: { - name: currentOperation.name, - collectionId: currentOperation.collection.id, - query: queryEditor?.getValue(), - variables: variableEditor?.getValue(), - headers: headerEditor?.getValue(), - operationId: currentOperation.id, - }, - }); - if (data) { - clearOperation(); - toast({ title: 'Updated!' }); - } - if (error) { - toast({ variant: 'destructive', title: error.message }); - } - }, - }, - { - label: 'Save as', - onClick: () => { - if (!collections.length) { - toast({ variant: 'destructive', title: 'Please create a collection first.' }); - return; - } - toggleOperationModal(); - }, - attrs: { 'data-cy': 'save-operation-as' }, - }, - ], - ]} - /> - - - ); -} - -function substituteVariablesInHeaders( - headers: Record, - environmentVariables: Record, -) { - return Object.fromEntries( - Object.entries(headers).map(([key, value]) => { - if (typeof value === 'string') { - // Replace all occurrences of `{{keyName}}` strings only if key exists in `environmentVariables` - value = value.replaceAll(/{{(?.*?)}}/g, (originalString, envKey: string) => { - return Object.hasOwn(environmentVariables, envKey) - ? String(environmentVariables[envKey]) - : originalString; - }); - } - return [key, value]; - }), - ); -} - -function LaboratoryPageContent(props: { - selectedOperationId?: string; - defaultLaboratoryTab: 'graphiql' | 'hive-laboratory'; - onLaboratoryTabChange: (tab: 'graphiql' | 'hive-laboratory') => void; -}) { - const { organizationSlug, projectSlug, targetSlug } = useSlugs('target'); - const [query] = useQuery({ - query: TargetLaboratoryPageQuery, - variables: { - organizationSlug, - projectSlug, - targetSlug, - }, - }); - const router = useRouter(); - const [isConnectLabModalOpen, toggleConnectLabModal] = useToggle(); - const [isFullScreen, setIsFullScreen] = useState(false); - const { resolvedTheme } = useTheme(); - const { collections } = useCollections({ - organizationSlug, - projectSlug, - targetSlug, - }); - - const userOperations = useMemo(() => { - const operations = collections.flatMap(collection => - collection.operations.edges.map(o => o.node.id), - ); - return new Set(operations); - }, [collections]); - - const sdl = query.data?.target?.latestSchemaVersion?.sdl; - const schema = useMemo(() => (sdl ? buildSchema(sdl) : null), [sdl]); - - const [actualSelectedApiEndpoint, setEndpointType] = useApiTabValueState( - query.data?.target?.graphqlEndpointUrl ?? null, - ); - - const mockEndpoint = `${location.origin}/api/lab/${organizationSlug}/${projectSlug}/${targetSlug}`; - const target = query.data?.target; - - const preflight = usePreflight({ target: target ?? null }); - - const fetcher = useMemo(() => { - return async (params, opts) => { - const url = - (actualSelectedApiEndpoint === 'linkedApi' ? target?.graphqlEndpointUrl : undefined) ?? - mockEndpoint; - - return new Repeater(async (push, stop) => { - let isPreflightExecutionDone = false; - // eslint-disable-next-line @typescript-eslint/no-floating-promises - stop.then(() => { - if (!isPreflightExecutionDone) { - preflight.abortExecution(); - } - }); - - let preflightResultData: PreflightResultData; - try { - preflightResultData = await preflight.execute(); - } catch (err: unknown) { - if (err instanceof Error === false) { - throw err; - } - const formatError = JSON.stringify( - { - name: err.name, - message: err.message, - }, - null, - 2, - ); - const error = new Error(`Error during preflight execution:\n\n${formatError}`); - // We only want to expose the error message, not the whole stack trace. - delete error.stack; - stop(error); - return; - } finally { - isPreflightExecutionDone = true; - } - - const headers = { - // We want to prevent users from interpolating environment variables into - // their preflight headers. So, apply substitution BEFORE merging - // in preflight headers. - // - ...substituteVariablesInHeaders( - opts?.headers ?? {}, - preflightResultData.environmentVariables, - ), - ...Object.fromEntries(preflightResultData.request.headers), - }; - - const graphiqlFetcher = createGraphiQLFetcher({ url, fetch }); - const result = await graphiqlFetcher(params, { - ...opts, - headers, - }); - - if (isAsyncIterable(result)) { - // eslint-disable-next-line @typescript-eslint/no-floating-promises - stop.then( - () => 'return' in result && result.return instanceof Function && result.return(), - ); - try { - for await (const value of result) { - await push(value); - } - stop(); - } catch (err) { - const error = new Error(err instanceof Error ? err.message : 'Unexpected error.'); - // We only want to expose the error message, not the whole stack trace. - delete error.stack; - stop(error); - return; - } - } - - return result; - }); - }; - }, [ - target?.graphqlEndpointUrl, - actualSelectedApiEndpoint, - preflight.execute, - preflight.isEnabled, - ]); - - const FullScreenIcon = isFullScreen ? MinimizeIcon : MaximizeIcon; - - const handleTabChange = useCallback>( - ({ tabs, activeTabIndex }) => { - const activeTab = tabs.find((_, index) => index === activeTabIndex)!; - // Set search params while clicking on tab - void router.navigate({ - to: '/$organizationSlug/$projectSlug/$targetSlug/laboratory', - params: { - organizationSlug, - projectSlug, - targetSlug, - }, - search: { operation: userOperations.has(activeTab.id) ? activeTab.id : undefined }, - }); - }, - [userOperations], - ); - - if (query.error) { - return ( - - ); - } - - return ( - <> - {preflight.iframeElement} -
-
-
- Laboratory -
- - props.onLaboratoryTabChange(value as 'graphiql' | 'hive-laboratory') - } - options={[ - { value: 'graphiql', label: 'GraphiQL' }, - { - value: 'hive-laboratory', - label: ( - <> - Hive Laboratory - - - ), - }, - ]} - /> -
- Explore your GraphQL schema and run queries against your GraphQL API. -

- -

-
-
-
- {query.data && !query.data.target?.graphqlEndpointUrl ? ( - - - - ) : null} - -
-
- Query - { - setEndpointType(newValue as 'mockApi' | 'linkedApi'); - }} - aria-label="Query endpoint" - /> -
-
-
- - - - {!query.fetching && !query.stale && ( - - - - - - - {({ prettify }) => ( - <> - {query.data?.target?.viewerCanModifyLaboratory && } - - {/* if people have no modify access they should still be able to format their own queries. */} - {(query.data?.target?.viewerCanModifyLaboratory === true || - !props.selectedOperationId) && - prettify} - - )} - - -
- {preflight.isEnabled ? ( - - ) : null} -
-
-
-
- )} - - - ); -} - -export function TargetLaboratoryPage(props: { - selectedOperationId: string | undefined; - defaultLaboratoryTab: 'graphiql' | 'hive-laboratory'; - onLaboratoryTabChange: (tab: 'graphiql' | 'hive-laboratory') => void; -}) { - return ( - <> - - - - - - - - - ); -} - -function useApiTabValueState(graphqlEndpointUrl: string | null) { - const [state, setState] = useResetState<'mockApi' | 'linkedApi'>(() => { - const value = localStorage.getItem('hive:laboratory-tab-value'); - if (!value || !['mockApi', 'linkedApi'].includes(value)) { - return graphqlEndpointUrl ? 'linkedApi' : 'mockApi'; - } - - if (value === 'linkedApi' && graphqlEndpointUrl) { - return 'linkedApi'; - } - - return 'mockApi'; - }, [graphqlEndpointUrl]); - - return [ - state, - useCallback( - (state: 'mockApi' | 'linkedApi') => { - localStorage.setItem('hive:laboratory-tab-value', state); - setState(state); - }, - [setState], - ), - ] as const; -} - -function PreflightLogs(props: { logs: LogRecord[]; onClear: () => void }) { - const [isOpen, setIsOpen] = useState(false); - const consoleRef = useRef(null); - useEffect(() => { - const consoleEl = consoleRef.current; - consoleEl?.scroll({ top: consoleEl.scrollHeight, behavior: 'smooth' }); - }, [props.logs, isOpen]); - - return ( -
- - ) : null - } - panelDataCy="logs" - > - -
- {props.logs.length === 0 ? ( -
-

No logs available

-

Execute a query to see logs

-
- ) : ( - props.logs.map((log, index) => ) - )} -
-
-
-
- ); -} diff --git a/packages/web/app/src/routes/target/laboratory.tsx b/packages/web/app/src/routes/target/laboratory.tsx index d0a98da53fb..85c362de8c7 100644 --- a/packages/web/app/src/routes/target/laboratory.tsx +++ b/packages/web/app/src/routes/target/laboratory.tsx @@ -1,7 +1,5 @@ -import { useLocalStorage } from '@/lib/hooks'; import { requireLayoutFlag } from '@/lib/route-utils'; -import { TargetLaboratoryPage } from '@/pages/target-laboratory'; -import { TargetLaboratoryPage as TargetLaboratoryPageNew } from '@/pages/target-laboratory-new'; +import { TargetLaboratoryPage } from '@/pages/target-laboratory-new'; import { createRoute } from '@tanstack/react-router'; import { targetRoute } from './route'; @@ -11,28 +9,8 @@ export const targetLaboratoryRoute = createRoute({ validateSearch: () => ({}) as { operation?: string; operationString?: string }, loader: loader => requireLayoutFlag.target(loader, 'viewerCanViewLaboratory'), component: function TargetLaboratoryRoute() { - const [laboratoryTab, setLaboratoryTab] = useLocalStorage( - 'hive:laboratory:type', - 'hive-laboratory', - ); const { operation } = targetLaboratoryRoute.useSearch(); - if (laboratoryTab === 'hive-laboratory') { - return ( - - ); - } - - return ( - - ); + return ; }, }); From 80d2f96c4cfc5c435be849bad26d3f6187384d71 Mon Sep 17 00:00:00 2001 From: Jonathan Brennan Date: Tue, 13 Oct 2026 15:16:18 -0500 Subject: [PATCH 02/11] Remove the GraphiQL collections modals, forms and cache updaters --- .../laboratory/collection-form.spec.tsx | 76 ------ .../target/laboratory/collection-form.tsx | 72 ----- .../laboratory/create-collection-modal.tsx | 245 ------------------ .../laboratory/create-operation-modal.tsx | 191 -------------- .../laboratory/delete-collection-modal.tsx | 103 -------- .../laboratory/delete-operation-modal.tsx | 112 -------- .../laboratory/edit-operation-modal.tsx | 175 ------------- .../target/laboratory/operation-form.spec.tsx | 89 ------- .../target/laboratory/operation-form.tsx | 96 ------- .../form/form.call-sites.preview.tsx | 75 ------ packages/web/app/src/index.css | 4 - .../lib/hooks/laboratory/use-collections.ts | 72 ----- .../hooks/laboratory/use-current-operation.ts | 22 -- .../laboratory/use-sync-operation-state.ts | 33 --- packages/web/app/src/lib/urql-cache.ts | 90 ------- 15 files changed, 1455 deletions(-) delete mode 100644 packages/web/app/src/components/target/laboratory/collection-form.spec.tsx delete mode 100644 packages/web/app/src/components/target/laboratory/collection-form.tsx delete mode 100644 packages/web/app/src/components/target/laboratory/create-collection-modal.tsx delete mode 100644 packages/web/app/src/components/target/laboratory/create-operation-modal.tsx delete mode 100644 packages/web/app/src/components/target/laboratory/delete-collection-modal.tsx delete mode 100644 packages/web/app/src/components/target/laboratory/delete-operation-modal.tsx delete mode 100644 packages/web/app/src/components/target/laboratory/edit-operation-modal.tsx delete mode 100644 packages/web/app/src/components/target/laboratory/operation-form.spec.tsx delete mode 100644 packages/web/app/src/components/target/laboratory/operation-form.tsx delete mode 100644 packages/web/app/src/lib/hooks/laboratory/use-collections.ts delete mode 100644 packages/web/app/src/lib/hooks/laboratory/use-sync-operation-state.ts diff --git a/packages/web/app/src/components/target/laboratory/collection-form.spec.tsx b/packages/web/app/src/components/target/laboratory/collection-form.spec.tsx deleted file mode 100644 index 5b0f08cc3a7..00000000000 --- a/packages/web/app/src/components/target/laboratory/collection-form.spec.tsx +++ /dev/null @@ -1,76 +0,0 @@ -// @vitest-environment jsdom -import { useForm } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { act, fireEvent, render, screen } from '@testing-library/react'; -import { - COLLECTION_FORM_ID, - CollectionForm, - CollectionFormSchema, - type CollectionFormValues, -} from './collection-form'; - -/** Mirrors the dialog: the submit button sits in its footer, outside the form. */ -function Harness(props: { onSubmit: (values: CollectionFormValues) => void }) { - const form = useForm({ - mode: 'onChange', - resolver: zodResolver(CollectionFormSchema), - defaultValues: { name: '', description: '' }, - }); - return ( - <> - - - - ); -} - -// The button is enabled by the form's validity, which the resolver settles a tick after mount. -async function renderForm(onSubmit: (values: CollectionFormValues) => void) { - render(); - await act(async () => {}); -} - -const nameInput = () => screen.getByLabelText('Collection Name') as HTMLInputElement; -const addButton = () => screen.getByRole('button', { name: 'Add' }) as HTMLButtonElement; - -describe('CollectionForm', () => { - it('names the inputs for the e2e flow and holds the footer button until the name is valid', async () => { - await renderForm(() => {}); - expect(nameInput().getAttribute('name')).toBe('name'); - expect(screen.getByLabelText('Collection Description').getAttribute('name')).toBe( - 'description', - ); - expect(addButton().disabled).toBe(true); - - await act(async () => { - fireEvent.change(nameInput(), { target: { value: 'a' } }); - }); - expect(screen.getByText('Collection name must be at least 2 characters long')).toBeTruthy(); - expect(addButton().disabled).toBe(true); - - await act(async () => { - fireEvent.change(nameInput(), { target: { value: 'a'.repeat(51) } }); - }); - expect(screen.getByText('Collection name must be at most 50 characters long')).toBeTruthy(); - - await act(async () => { - fireEvent.change(nameInput(), { target: { value: 'Smoke tests' } }); - }); - expect(addButton().disabled).toBe(false); - }); - - it('is submitted by the footer button with the name and an optional description', async () => { - const onSubmit = vi.fn(); - await renderForm(onSubmit); - await act(async () => { - fireEvent.change(nameInput(), { target: { value: 'Smoke tests' } }); - }); - await act(async () => { - fireEvent.click(addButton()); - }); - expect(onSubmit).toHaveBeenCalledTimes(1); - expect(onSubmit.mock.calls[0][0]).toEqual({ name: 'Smoke tests', description: '' }); - }); -}); diff --git a/packages/web/app/src/components/target/laboratory/collection-form.tsx b/packages/web/app/src/components/target/laboratory/collection-form.tsx deleted file mode 100644 index edbc2f2e1c9..00000000000 --- a/packages/web/app/src/components/target/laboratory/collection-form.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { type UseFormReturn } from 'react-hook-form'; -import { z } from 'zod'; -import { Callout } from '@/components/ui/callout'; -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from '@/components/ui/primitives/form/form'; -import { Input } from '@/components/ui/primitives/input/input'; - -/** The dialog footer's submit button lives outside the form and targets it by this id. */ -export const COLLECTION_FORM_ID = 'create-collection-form'; - -export const CollectionFormSchema = z.object({ - name: z - .string({ - required_error: 'Collection name is required', - }) - .min(2, { - message: 'Collection name must be at least 2 characters long', - }) - .max(50, { - message: 'Collection name must be at most 50 characters long', - }), - description: z.string().optional(), -}); - -export type CollectionFormValues = z.infer; - -/** The body of the create and update collection dialog. The dialog owns the form state. */ -export function CollectionForm(props: { - form: UseFormReturn; - onSubmit: (values: CollectionFormValues) => void | Promise; -}) { - const { form } = props; - return ( -
- ( - - - - - - - - )} - /> - ( - - - - - - - - )} - /> - - This collection will be available to everyone in the organization - - - ); -} diff --git a/packages/web/app/src/components/target/laboratory/create-collection-modal.tsx b/packages/web/app/src/components/target/laboratory/create-collection-modal.tsx deleted file mode 100644 index 08ce97a76a4..00000000000 --- a/packages/web/app/src/components/target/laboratory/create-collection-modal.tsx +++ /dev/null @@ -1,245 +0,0 @@ -import { ReactElement, useEffect } from 'react'; -import { useForm, UseFormReturn } from 'react-hook-form'; -import { useMutation, useQuery } from 'urql'; -import { Button } from '@/components/ui/primitives/button/button'; -import { Dialog } from '@/components/ui/primitives/overlays/dialog/dialog'; -import { graphql } from '@/gql'; -import { useSlugs } from '@/lib/hooks'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { - COLLECTION_FORM_ID, - CollectionForm, - CollectionFormSchema, - type CollectionFormValues, -} from './collection-form'; - -const CollectionQuery = graphql(` - query Collection($selector: TargetSelectorInput!, $id: ID!) { - target(reference: { bySelector: $selector }) { - id - documentCollection(id: $id) { - id - name - description - } - } - } -`); - -const CreateCollectionMutation = graphql(` - mutation CreateCollection( - $selector: TargetSelectorInput! - $input: CreateDocumentCollectionInput! - ) { - createDocumentCollection(selector: $selector, input: $input) { - error { - message - } - ok { - updatedTarget { - id - documentCollections { - edges { - cursor - node { - id - name - } - } - } - } - collection { - id - name - description - operations(first: 100) { - edges { - cursor - node { - id - name - } - cursor - } - } - } - } - } - } -`); - -const UpdateCollectionMutation = graphql(` - mutation UpdateCollection( - $selector: TargetSelectorInput! - $input: UpdateDocumentCollectionInput! - ) { - updateDocumentCollection(selector: $selector, input: $input) { - error { - message - } - ok { - updatedTarget { - id - documentCollections { - edges { - node { - id - name - } - cursor - } - } - } - collection { - id - name - description - operations(first: 100) { - edges { - cursor - node { - id - name - } - } - } - } - } - } - } -`); - -export function CreateCollectionModal(props: { - isOpen: boolean; - toggleModalOpen: () => void; - collectionId?: string; -}): ReactElement { - const { organizationSlug, projectSlug, targetSlug } = useSlugs('target'); - const { isOpen, toggleModalOpen, collectionId } = props; - const [mutationCreate, mutateCreate] = useMutation(CreateCollectionMutation); - const [mutationUpdate, mutateUpdate] = useMutation(UpdateCollectionMutation); - - const [{ data, error: collectionError, fetching: loadingCollection }] = useQuery({ - query: CollectionQuery, - variables: { - id: collectionId!, - selector: { - targetSlug, - organizationSlug, - projectSlug, - }, - }, - pause: !collectionId, - }); - - const errorCombined = mutationCreate.error || collectionError || mutationUpdate.error; - const fetching = loadingCollection; - - const form = useForm({ - mode: 'onChange', - resolver: zodResolver(CollectionFormSchema), - defaultValues: { - name: '', - description: '', - }, - }); - - useEffect(() => { - if (!collectionId) { - form.reset(); - } else if (data) { - const { documentCollection } = data.target!; - if (documentCollection) { - void form.setValue('name', documentCollection.name); - void form.setValue('description', documentCollection.description || ''); - } - } - }, [data, collectionId]); - - async function onSubmit(values: CollectionFormValues) { - const { error } = collectionId - ? await mutateUpdate({ - selector: { - targetSlug, - organizationSlug, - projectSlug, - }, - input: { - collectionId, - name: values.name, - description: values.description, - }, - }) - : await mutateCreate({ - selector: { - targetSlug, - organizationSlug, - projectSlug, - }, - input: values, - }); - if (!error || errorCombined) { - form.reset(); - toggleModalOpen(); - } - } - - return ( - - ); -} - -export function CreateCollectionModalContent(props: { - isOpen: boolean; - toggleModalOpen: () => void; - onSubmit: (values: CollectionFormValues) => void; - form: UseFormReturn; - collectionId?: string; - fetching: boolean; -}) { - return ( - - - - - } - > - {!props.fetching && } - - ); -} diff --git a/packages/web/app/src/components/target/laboratory/create-operation-modal.tsx b/packages/web/app/src/components/target/laboratory/create-operation-modal.tsx deleted file mode 100644 index f079f7f2ab4..00000000000 --- a/packages/web/app/src/components/target/laboratory/create-operation-modal.tsx +++ /dev/null @@ -1,191 +0,0 @@ -import { ReactElement } from 'react'; -import { useForm, UseFormReturn } from 'react-hook-form'; -import { useMutation } from 'urql'; -import { Button } from '@/components/ui/primitives/button/button'; -import { Dialog } from '@/components/ui/primitives/overlays/dialog/dialog'; -import { useToast } from '@/components/ui/primitives/toast/toast'; -import { graphql } from '@/gql'; -import { useSlugs } from '@/lib/hooks'; -import { - DocumentCollectionOperation, - useCollections, -} from '@/lib/hooks/laboratory/use-collections'; -import { useEditorContext } from '@graphiql/react'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { OperationForm, OperationFormSchema, type OperationFormValues } from './operation-form'; - -const CREATE_OPERATION_FORM_ID = 'create-operation-form'; - -const CreateOperationMutation = graphql(` - mutation CreateOperation( - $selector: TargetSelectorInput! - $input: CreateDocumentCollectionOperationInput! - ) { - createOperationInDocumentCollection(selector: $selector, input: $input) { - error { - message - } - ok { - operation { - id - name - } - updatedTarget { - id - documentCollections { - edges { - cursor - node { - id - operations { - edges { - node { - id - } - cursor - } - } - } - } - } - } - } - } - } -`); - -export type CreateOperationMutationType = typeof CreateOperationMutation; - -export function CreateOperationModal(props: { - isOpen: boolean; - close: () => void; - onSaveSuccess: (args: { id: string; name: string }) => void; -}): ReactElement { - const { organizationSlug, projectSlug, targetSlug } = useSlugs('target'); - const { toast } = useToast(); - const { isOpen, close, onSaveSuccess } = props; - const [, mutateCreate] = useMutation(CreateOperationMutation); - - const { collections, fetching } = useCollections({ - organizationSlug, - projectSlug, - targetSlug, - }); - const { queryEditor, variableEditor, headerEditor } = useEditorContext({ - nonNull: true, - }); - - const form = useForm({ - mode: 'onChange', - resolver: zodResolver(OperationFormSchema), - defaultValues: { - name: '', - collectionId: '', - }, - disabled: fetching, - }); - - async function onSubmit(values: OperationFormValues) { - const result = await mutateCreate({ - selector: { - targetSlug, - organizationSlug, - projectSlug, - }, - input: { - name: values.name, - collectionId: values.collectionId, - query: queryEditor?.getValue() ?? '', - variables: variableEditor?.getValue(), - headers: headerEditor?.getValue(), - }, - }); - const error = result.error || result.data?.createOperationInDocumentCollection.error; - - if (error) { - toast({ - title: 'Failed to create operation', - description: error.message, - variant: 'destructive', - }); - } else { - const operation = result?.data?.createOperationInDocumentCollection.ok?.operation; - if (operation) { - onSaveSuccess({ id: operation.id, name: operation.name }); - } - form.reset(); - close(); - toast({ - title: 'Operation created', - description: `Operation "${values.name}" added to collection "${collections.find(c => c.id === values.collectionId)?.name}"`, - }); - } - } - - return ( - - ); -} - -export function CreateOperationModalContent(props: { - isOpen: boolean; - close: () => void; - onSubmit: (values: OperationFormValues) => void; - form: UseFormReturn; - fetching: boolean; - collections: DocumentCollectionOperation[]; -}): ReactElement { - return ( - { - props.close(); - props.form.reset(); - }} - width="lg" - attrs={{ 'data-cy': 'create-operation-modal' }} - title="Create Operation" - description="Create a new operation and add it to a collection" - footer={ - <> - - - - } - > - {!props.fetching && ( - - )} - - ); -} diff --git a/packages/web/app/src/components/target/laboratory/delete-collection-modal.tsx b/packages/web/app/src/components/target/laboratory/delete-collection-modal.tsx deleted file mode 100644 index 1dbee580b03..00000000000 --- a/packages/web/app/src/components/target/laboratory/delete-collection-modal.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { useMutation } from 'urql'; -import { AlertDialog } from '@/components/ui/primitives/overlays/alert-dialog/alert-dialog'; -import { useToast } from '@/components/ui/primitives/toast/toast'; -import { graphql } from '@/gql'; -import { useSlugs } from '@/lib/hooks'; - -const DeleteCollectionMutation = graphql(` - mutation DeleteCollection($selector: TargetSelectorInput!, $id: ID!) { - deleteDocumentCollection(selector: $selector, id: $id) { - error { - message - } - ok { - deletedId - updatedTarget { - id - documentCollections { - edges { - cursor - node { - id - } - } - } - } - } - } - } -`); - -export type DeleteCollectionMutationType = typeof DeleteCollectionMutation; - -export function DeleteCollectionModal(props: { - isOpen: boolean; - toggleModalOpen: () => void; - collectionId: string; -}) { - const { organizationSlug, projectSlug, targetSlug } = useSlugs('target'); - const { toast } = useToast(); - const { isOpen, toggleModalOpen, collectionId } = props; - const [, mutate] = useMutation(DeleteCollectionMutation); - - const handleDelete = async () => { - const { error } = await mutate({ - id: collectionId, - selector: { - targetSlug, - organizationSlug, - projectSlug, - }, - }); - toggleModalOpen(); - if (error) { - toast({ - title: 'Failed to delete collection', - description: error.message, - variant: 'destructive', - }); - } else { - toast({ - title: 'Collection deleted', - description: 'The collection has been successfully deleted', - variant: 'default', - }); - } - }; - - return ( - - ); -} - -export function DeleteCollectionModalContent(props: { - isOpen: boolean; - toggleModalOpen: () => void; - handleDelete: () => void; -}) { - return ( - - Are you sure you wish to delete this collection? -
- This action is irreversible! - - } - confirm={{ - label: 'Delete', - variant: 'destructive', - onClick: props.handleDelete, - 'data-cy': 'confirm', - }} - /> - ); -} diff --git a/packages/web/app/src/components/target/laboratory/delete-operation-modal.tsx b/packages/web/app/src/components/target/laboratory/delete-operation-modal.tsx deleted file mode 100644 index 3fded57425a..00000000000 --- a/packages/web/app/src/components/target/laboratory/delete-operation-modal.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import { ReactElement } from 'react'; -import { useMutation } from 'urql'; -import { AlertDialog } from '@/components/ui/primitives/overlays/alert-dialog/alert-dialog'; -import { useToast } from '@/components/ui/primitives/toast/toast'; -import { graphql } from '@/gql'; -import { useSlugs } from '@/lib/hooks'; - -const DeleteOperationMutation = graphql(` - mutation DeleteOperation($selector: TargetSelectorInput!, $id: ID!) { - deleteOperationInDocumentCollection(selector: $selector, id: $id) { - error { - message - } - ok { - deletedId - updatedTarget { - id - documentCollections { - edges { - cursor - node { - id - operations { - edges { - node { - id - } - cursor - } - } - } - } - } - } - } - } - } -`); - -export type DeleteOperationMutationType = typeof DeleteOperationMutation; - -export function DeleteOperationModal(props: { - isOpen: boolean; - toggleModalOpen: () => void; - operationId: string; -}): ReactElement { - const { organizationSlug, projectSlug, targetSlug } = useSlugs('target'); - const { toast } = useToast(); - const { isOpen, toggleModalOpen, operationId } = props; - const [, mutate] = useMutation(DeleteOperationMutation); - - const handleDelete = async () => { - const { error } = await mutate({ - id: operationId, - selector: { - targetSlug, - organizationSlug, - projectSlug, - }, - }); - toggleModalOpen(); - if (error) { - toast({ - title: 'Error', - description: error.message, - variant: 'destructive', - }); - } else { - toast({ - title: 'Operation Deleted', - description: 'The operation has been successfully deleted.', - variant: 'default', - }); - } - }; - - return ( - - ); -} - -export function DeleteOperationModalContent(props: { - isOpen: boolean; - toggleModalOpen: () => void; - handleDelete: () => void; -}): ReactElement { - return ( - - Do you really want to delete this operation? -
- This action is irreversible! - - } - confirm={{ - label: 'Delete', - variant: 'destructive', - onClick: props.handleDelete, - 'data-cy': 'confirm', - }} - /> - ); -} diff --git a/packages/web/app/src/components/target/laboratory/edit-operation-modal.tsx b/packages/web/app/src/components/target/laboratory/edit-operation-modal.tsx deleted file mode 100644 index 82ee086eaa6..00000000000 --- a/packages/web/app/src/components/target/laboratory/edit-operation-modal.tsx +++ /dev/null @@ -1,175 +0,0 @@ -import { ReactElement, useMemo } from 'react'; -import { useForm, UseFormReturn } from 'react-hook-form'; -import { useMutation } from 'urql'; -import { Button } from '@/components/ui/primitives/button/button'; -import { Dialog } from '@/components/ui/primitives/overlays/dialog/dialog'; -import { useToast } from '@/components/ui/primitives/toast/toast'; -import { graphql } from '@/gql'; -import { useSlugs } from '@/lib/hooks'; -import { useCollections } from '@/lib/hooks/laboratory/use-collections'; -import { useEditorContext } from '@graphiql/react'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { OperationForm, OperationFormSchema, type OperationFormValues } from './operation-form'; - -const EDIT_OPERATION_FORM_ID = 'edit-operation-form'; - -const UpdateOperationNameMutation = graphql(` - mutation UpdateOperation( - $selector: TargetSelectorInput! - $input: UpdateDocumentCollectionOperationInput! - ) { - updateOperationInDocumentCollection(selector: $selector, input: $input) { - error { - message - } - ok { - operation { - id - name - query - variables - headers - } - } - } - } -`); - -export const EditOperationModal = (props: { - operationId: string; - close: () => void; -}): ReactElement => { - const { organizationSlug, projectSlug, targetSlug } = useSlugs('target'); - const { toast } = useToast(); - const [updateOperationNameState, mutate] = useMutation(UpdateOperationNameMutation); - const { collections } = useCollections({ - organizationSlug, - projectSlug, - targetSlug, - }); - const { setTabState } = useEditorContext({ nonNull: true }); - - const [collection, operation] = useMemo(() => { - for (const collection of collections) { - for (const operation of collection.operations.edges) { - if (operation.node.id === props.operationId) { - return [collection, operation.node] as const; - } - } - } - return [null, null] as const; - }, [collections]); - - const form = useForm({ - mode: 'all', - resolver: zodResolver(OperationFormSchema), - defaultValues: { - name: operation?.name || '', - collectionId: collection?.id || '', - }, - }); - - async function onSubmit(values: OperationFormValues) { - const response = await mutate({ - selector: { - targetSlug, - organizationSlug, - projectSlug, - }, - input: { - collectionId: values.collectionId, - operationId: props.operationId, - name: values.name, - }, - }); - const error = response.error || response.data?.updateOperationInDocumentCollection?.error; - - if (!error) { - // Update tab title - setTabState(state => ({ - ...state, - tabs: state.tabs.map(tab => - tab.id === props.operationId ? { ...tab, title: values.name } : tab, - ), - })); - props.close(); - toast({ - title: 'Operation Updated', - description: 'Operation has been updated successfully', - variant: 'default', - }); - } else { - toast({ - title: 'Error', - description: error.message, - variant: 'destructive', - }); - } - } - - return ( - - ); -}; - -export const EditOperationModalContent = (props: { - fetching: boolean; - isOpen: boolean; - close: () => void; - form: UseFormReturn; - onSubmit: (values: OperationFormValues) => void; - opreationId?: string; -}): ReactElement => { - return ( - { - props.close(); - props.form.reset(); - }} - width="lg" - attrs={{ 'data-cy': 'edit-operation-modal' }} - title="Edit Operation" - description="Update the operation name" - footer={ - <> - - - - } - > - {!props.fetching && ( - - )} - - ); -}; diff --git a/packages/web/app/src/components/target/laboratory/operation-form.spec.tsx b/packages/web/app/src/components/target/laboratory/operation-form.spec.tsx deleted file mode 100644 index 58086b7b12a..00000000000 --- a/packages/web/app/src/components/target/laboratory/operation-form.spec.tsx +++ /dev/null @@ -1,89 +0,0 @@ -// @vitest-environment jsdom -import { useForm } from 'react-hook-form'; -import type { DocumentCollectionOperation } from '@/lib/hooks/laboratory/use-collections'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { act, fireEvent, render, screen } from '@testing-library/react'; -import { OperationForm, OperationFormSchema, type OperationFormValues } from './operation-form'; - -const collections = [ - { id: 'col-1', name: 'Smoke tests', description: 'Runs on deploy', operations: { edges: [] } }, - { id: 'col-2', name: 'Playground', description: null, operations: { edges: [] } }, -] as unknown as DocumentCollectionOperation[]; - -/** Mirrors the dialogs: the submit button sits in the footer, outside the form. */ -function Harness(props: { - onSubmit: (values: OperationFormValues) => void; - collections?: DocumentCollectionOperation[]; - collectionId?: string; -}) { - const form = useForm({ - mode: 'onChange', - resolver: zodResolver(OperationFormSchema), - defaultValues: { name: '', collectionId: props.collectionId ?? '' }, - }); - return ( - <> - - - - ); -} - -const nameInput = () => screen.getByLabelText('Operation Name') as HTMLInputElement; -const saveButton = () => screen.getByRole('button', { name: 'Save' }) as HTMLButtonElement; -const collectionLabel = 'Which collection would you like to save this operation to?'; - -describe('OperationForm', () => { - it('shows the collection picker only when given collections, with its e2e hooks', () => { - const { rerender } = render( {}} />); - expect(nameInput().getAttribute('name')).toBe('name'); - expect(screen.queryByText(collectionLabel)).toBeNull(); - - rerender( {}} collections={collections} />); - const trigger = screen.getByLabelText(collectionLabel); - expect(trigger.getAttribute('data-cy')).toBe('collection-select-trigger'); - expect(trigger.textContent).toContain('Select a Collection'); - }); - - it('applies the name rules and holds the footer button until they pass', async () => { - render( {}} collections={collections} collectionId="col-1" />); - await act(async () => {}); - expect(saveButton().disabled).toBe(true); - - await act(async () => { - fireEvent.change(nameInput(), { target: { value: 'ab' } }); - }); - expect(screen.getByText('Operation name must be at least 3 characters long')).toBeTruthy(); - expect(saveButton().disabled).toBe(true); - - await act(async () => { - fireEvent.change(nameInput(), { target: { value: 'a'.repeat(51) } }); - }); - expect(screen.getByText('Operation name must be less than 50 characters long')).toBeTruthy(); - - await act(async () => { - fireEvent.change(nameInput(), { target: { value: 'ListUsers' } }); - }); - expect(saveButton().disabled).toBe(false); - }); - - it('hands over the name with the chosen collection', async () => { - const onSubmit = vi.fn(); - render(); - await act(async () => { - fireEvent.change(nameInput(), { target: { value: 'ListUsers' } }); - }); - await act(async () => { - fireEvent.click(saveButton()); - }); - expect(onSubmit).toHaveBeenCalledTimes(1); - expect(onSubmit.mock.calls[0][0]).toEqual({ name: 'ListUsers', collectionId: 'col-2' }); - }); -}); diff --git a/packages/web/app/src/components/target/laboratory/operation-form.tsx b/packages/web/app/src/components/target/laboratory/operation-form.tsx deleted file mode 100644 index 985143e9d68..00000000000 --- a/packages/web/app/src/components/target/laboratory/operation-form.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { type UseFormReturn } from 'react-hook-form'; -import { z } from 'zod'; -import { Select } from '@/components/ui/primitives/floating/select/select'; -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from '@/components/ui/primitives/form/form'; -import { Input } from '@/components/ui/primitives/input/input'; -import type { DocumentCollectionOperation } from '@/lib/hooks/laboratory/use-collections'; - -export const OperationFormSchema = z.object({ - name: z - .string({ - required_error: 'Operation name is required', - }) - .min(3, { - message: 'Operation name must be at least 3 characters long', - }) - .max(50, { - message: 'Operation name must be less than 50 characters long', - }), - collectionId: z.string({ - required_error: 'Collection is required', - }), -}); - -export type OperationFormValues = z.infer; - -/** - * The body of the create and edit operation dialogs. Creating also picks the collection; editing - * only renames, so it passes no collections. The dialog owns the form state and submits from its - * footer by the form's id. - */ -export function OperationForm(props: { - form: UseFormReturn; - onSubmit: (values: OperationFormValues) => void | Promise; - id: string; - collections?: readonly DocumentCollectionOperation[]; -}) { - const { form } = props; - return ( -
- ( - - - - - - - - )} - /> - {props.collections ? ( - ( - - - - setValue(e.target.value)} onSurface="raised" /> - - ); -} - -export function PromptManager() { - const { prompts, closePrompt } = usePromptManager(); - // The last prompt stays up through the close transition. - const prompt = useKeepPreviousData(prompts[0], prompts.length === 0); - - return ( - 0} - onOpenChange={open => { - if (!open && prompt) { - closePrompt(prompt.id, null); - } - }} - closeButton={false} - dismissible={false} - title={prompt?.title ?? ''} - description={prompt?.description} - attrs={{ 'data-cy': 'prompt' }} - footer={ - prompt ? ( - <> - - - - ) : null - } - > - {prompt ? ( - - ) : null} - - ); -} - -interface PromptOptions { - id: number; - title: string; - description?: string; - defaultValue?: string; -} - -interface PromptItem extends PromptOptions { - resolve: (value: string | null) => void; -} - -interface PromptContextType { - openPrompt: (options: PromptOptions) => Promise; - closePrompt: (id: number, value: string | null) => void; - prompts: PromptItem[]; -} - -const PromptContext = createContext(undefined); - -export const PromptProvider: FC<{ children: ReactNode }> = ({ children }) => { - const [prompts, setPrompts] = useState([]); - - const openPrompt = useCallback((options: PromptOptions): Promise => { - return new Promise(resolve => { - const newPrompt: PromptItem = { - ...options, - resolve, - }; - setPrompts(prevPrompts => [...prevPrompts, newPrompt]); - }); - }, []); - - const closePrompt = useCallback((id: number, value: string | null) => { - setPrompts(prevPrompts => { - const promptToClose = prevPrompts.find(p => p.id === id); - if (promptToClose) { - promptToClose.resolve(value); - } - return prevPrompts.filter(p => p.id !== id); - }); - }, []); - - return ( - - {children} - - ); -}; - -export const usePromptManager = () => { - const context = useContext(PromptContext); - if (!context) { - throw new Error('usePromptManager must be used within a PromptProvider'); - } - return context; -}; diff --git a/packages/web/app/src/lib/preflight/allowed-globals.ts b/packages/web/app/src/lib/preflight/allowed-globals.ts deleted file mode 100644 index 2962653ab4a..00000000000 --- a/packages/web/app/src/lib/preflight/allowed-globals.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * List all variables that we want to allow users to use inside their scripts - * - * initial list comes from https://github.com/postmanlabs/uniscope/blob/develop/lib/allowed-globals.js - */ -export const ALLOWED_GLOBALS = new Set([ - 'Array', - 'Atomics', - 'BigInt', - 'Boolean', - 'DataView', - 'Date', - 'Error', - 'EvalError', - 'Infinity', - 'JSON', - 'Map', - 'Math', - 'NaN', - 'Number', - 'Object', - 'Promise', - 'Proxy', - 'RangeError', - 'ReferenceError', - 'Reflect', - 'RegExp', - 'Set', - 'String', - 'Symbol', - 'SyntaxError', - 'TypeError', - 'URIError', - 'WeakMap', - 'WeakSet', - 'decodeURI', - 'decodeURIComponent', - 'encodeURI', - 'encodeURIComponent', - 'escape', - 'isFinite', - 'isNaN', - 'parseFloat', - 'parseInt', - 'undefined', - 'unescape', - // More global variables - 'btoa', - 'atob', - 'fetch', - 'setTimeout', - // We aren't allowing access to window.console, but we need to "allow" it - // here so a second argument isn't added for it below. - 'console', -]); diff --git a/packages/web/app/src/lib/preflight/graphiql-plugin.tsx b/packages/web/app/src/lib/preflight/graphiql-plugin.tsx deleted file mode 100644 index 9a91a0e8297..00000000000 --- a/packages/web/app/src/lib/preflight/graphiql-plugin.tsx +++ /dev/null @@ -1,875 +0,0 @@ -import { - ComponentPropsWithoutRef, - createContext, - ReactNode, - useCallback, - useContext, - useEffect, - useRef, - useState, -} from 'react'; -import { clsx } from 'clsx'; -import { InfoIcon, PencilIcon, PlayIcon, PowerIcon, XIcon } from 'lucide-react'; -import type { editor } from 'monaco-editor'; -import { useMutation } from 'urql'; -import { z } from 'zod'; -import { useMonacoTheme } from '@/components/theme/theme-provider'; -import { Subtitle } from '@/components/ui/page'; -import { Badge } from '@/components/ui/primitives/badge/badge'; -import { Button } from '@/components/ui/primitives/button/button'; -import { Dialog } from '@/components/ui/primitives/overlays/dialog/dialog'; -import { ScrollArea } from '@/components/ui/primitives/scroll-area/scroll-area'; -import { useToast } from '@/components/ui/primitives/toast/toast'; -import { usePromptManager } from '@/components/ui/prompt'; -import { FragmentType, graphql, useFragment } from '@/gql'; -import { useLocalStorage, useLocalStorageJson, useToggle } from '@/lib/hooks'; -import { GraphiQLPlugin } from '@graphiql/react'; -import { Editor as MonacoEditor, OnMount, type Monaco } from '@monaco-editor/react'; -import { captureException } from '@sentry/react'; -import { getRouteApi } from '@tanstack/react-router'; -import { Kit } from '../kit'; -import { cn } from '../utils'; -import labApiDefinitionRaw from './lab-api-declaration?raw'; -import { IFrameEvents, LogMessage } from './shared-types'; - -export type PreflightResultData = Omit; - -export const preflightPlugin: GraphiQLPlugin = { - icon: () => ( - - - - - - ), - title: 'Preflight Script', - content: PreflightContent, -}; - -const targetRoute = getRouteApi( - '/authenticated/with-header/$organizationSlug/$projectSlug/$targetSlug', -); - -const classes = { - monaco: clsx('*:bg-editor'), - monacoMini: clsx('h-32 *:rounded-md *:bg-editor'), - icon: clsx('absolute -left-5 top-px'), -}; - -function EditorTitle(props: { children: ReactNode; className?: string }) { - return ( -
- {props.children} -
- ); -} - -const sharedMonacoProps = { - className: classes.monaco, - options: { - minimap: { enabled: false }, - padding: { - top: 10, - }, - scrollbar: { - horizontalScrollbarSize: 6, - verticalScrollbarSize: 6, - }, - }, -} satisfies ComponentPropsWithoutRef; - -const monacoProps = { - env: { - ...sharedMonacoProps, - defaultLanguage: 'json', - options: { - ...sharedMonacoProps.options, - lineNumbers: 'off', - tabSize: 2, - }, - }, - script: { - ...sharedMonacoProps, - defaultLanguage: 'javascript', - options: { - ...sharedMonacoProps.options, - }, - }, -} satisfies Record<'script' | 'env', ComponentPropsWithoutRef>; - -function exposeMonacoEditorForE2E(name: string, editor: editor.IStandaloneCodeEditor) { - if (!import.meta.env.DEV) { - return; - } - - const global = window as unknown as { - __HIVE_E2E_MONACO_EDITORS?: Record; - }; - - global.__HIVE_E2E_MONACO_EDITORS ??= {}; - global.__HIVE_E2E_MONACO_EDITORS[name] = editor; -} - -const UpdatePreflightScriptMutation = graphql(` - mutation UpdatePreflightScript($input: UpdatePreflightScriptInput!) { - updatePreflightScript(input: $input) { - ok { - updatedTarget { - id - preflightScript { - id - sourceCode - } - } - } - error { - message - } - } - } -`); - -const PreflightScript_TargetFragment = graphql(` - fragment PreflightScript_TargetFragment on Target { - id - preflightScript { - id - sourceCode - } - viewerCanModifyPreflightScript - } -`); - -export type LogRecord = LogMessage | { type: 'separator' }; - -export const enum PreflightWorkerState { - running, - ready, -} - -function PromiseWithResolvers() { - let resolve: (value: T) => void; - let reject: (error: any) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - - return { - promise, - reject: reject!, - resolve: resolve!, - }; -} - -export function usePreflight(args: { - target: FragmentType | null; -}) { - const iframeRef = useRef(null); - const prompt = usePromptManager(); - - const target = useFragment(PreflightScript_TargetFragment, args.target); - const [isEnabled, setIsEnabled] = useLocalStorageJson( - // todo: ability to pass historical keys for seamless gradual migration to new key names. - // 'hive:laboratory:isPreflightEnabled', - 'hive:laboratory:isPreflightScriptEnabled', - z.boolean().default(false), - ); - const [environmentVariables, setEnvironmentVariables] = useLocalStorage( - 'hive:laboratory:environment', - '', - ); - const latestEnvironmentVariablesRef = useRef(environmentVariables); - useEffect(() => { - latestEnvironmentVariablesRef.current = environmentVariables; - }); - - const [state, setState] = useState(PreflightWorkerState.ready); - const [logs, setLogs] = useState([]); - - const abortExecutionRef = useRef void)>(null); - - async function execute( - script = target?.preflightScript?.sourceCode ?? '', - isPreview = false, - ): Promise { - const resultEnvironmentVariablesDecoded: PreflightResultData['environmentVariables'] = - Kit.tryOr( - () => JSON.parse(latestEnvironmentVariablesRef.current), - // todo: find a better solution than blowing away the user's - // invalid localStorage state. - // - // For example if the user has: - // - // { "foo": "bar } - // - // Then when they "Run Script" it will be replaced with: - // - // {} - // - () => ({}), - ); - const result: PreflightResultData = { - request: { - headers: [], - }, - environmentVariables: resultEnvironmentVariablesDecoded, - }; - - if (isPreview === false && !isEnabled) { - return result; - } - - const id = crypto.randomUUID(); - setState(PreflightWorkerState.running); - const now = Date.now(); - setLogs(prev => [ - ...prev, - { - level: 'log', - message: 'Running script...', - }, - ]); - - try { - const contentWindow = iframeRef.current?.contentWindow; - - if (!contentWindow) { - throw new Error('Could not load iframe embed.'); - } - - contentWindow.postMessage( - { - type: IFrameEvents.Incoming.Event.run, - id, - script, - // Preflight has read/write relationship with environment variables. - environmentVariables: result.environmentVariables, - } satisfies IFrameEvents.Incoming.EventData, - '*', - ); - - let isFinished = false; - const isFinishedD = PromiseWithResolvers(); - const openedPromptIds = new Set(); - - // eslint-disable-next-line no-inner-declarations - function setFinished() { - isFinished = true; - isFinishedD.resolve(); - } - - // eslint-disable-next-line no-inner-declarations - function closedOpenedPrompts() { - if (openedPromptIds.size) { - for (const promptId of openedPromptIds) { - prompt.closePrompt(promptId, null); - } - } - } - - // eslint-disable-next-line no-inner-declarations - async function eventHandler(ev: IFrameEvents.Outgoing.MessageEvent) { - if (ev.data.type === IFrameEvents.Outgoing.Event.prompt) { - const promptId = ev.data.promptId; - openedPromptIds.add(promptId); - await prompt - .openPrompt({ - id: promptId, - title: ev.data.message, - defaultValue: ev.data.defaultValue, - }) - .then(value => { - if (isFinished) { - // ignore prompt response if the script has already finished - return; - } - - openedPromptIds.delete(promptId); - contentWindow?.postMessage( - { - type: IFrameEvents.Incoming.Event.promptResponse, - id, - promptId, - value, - } satisfies IFrameEvents.Incoming.EventData, - '*', - ); - }); - return; - } - - if (ev.data.type === IFrameEvents.Outgoing.Event.result) { - const mergedEnvironmentVariables = { - ...result.environmentVariables, - ...ev.data.environmentVariables, - }; - result.environmentVariables = mergedEnvironmentVariables; - result.request.headers = ev.data.request.headers; - - // Cause the new state of environment variables to be - // written back to local storage. - - const mergedEnvironmentVariablesEncoded = JSON.stringify( - result.environmentVariables, - null, - 2, - ); - setEnvironmentVariables(mergedEnvironmentVariablesEncoded); - latestEnvironmentVariablesRef.current = mergedEnvironmentVariablesEncoded; - - setLogs(logs => [ - ...logs, - { - level: 'log', - message: `Done in ${(Date.now() - now) / 1000}s`, - }, - { - type: 'separator' as const, - }, - ]); - setFinished(); - return; - } - - if (ev.data.type === IFrameEvents.Outgoing.Event.error) { - const error = ev.data.error; - setLogs(logs => [ - ...logs, - { - level: 'error', - message: error.message, - line: error.line, - column: error.column, - }, - { - level: 'log', - message: 'Script failed', - }, - { - type: 'separator' as const, - }, - ]); - setFinished(); - closedOpenedPrompts(); - return; - } - - if (ev.data.type === IFrameEvents.Outgoing.Event.log) { - const log = ev.data.log; - setLogs(logs => [...logs, log]); - return; - } - - if (ev.data.type === IFrameEvents.Outgoing.Event.ready) { - console.debug('preflight sandbox graphiql plugin: noop iframe event:', ev.data); - return; - } - - if (ev.data.type === IFrameEvents.Outgoing.Event.start) { - console.debug('preflight sandbox graphiql plugin: noop iframe event:', ev.data); - return; - } - - // Window message events can be emitted from unknowable sources. - // For example when our e2e tests runs within Cypress GUI, we see a `MessageEvent` with `.data` of `{ vscodeScheduleAsyncWork: 3 }`. - // Since we cannot know if the event source is Preflight, we cannot perform an exhaustive check. - // - // Kit.neverCase(ev.data); - // - console.debug( - 'preflight sandbox graphiql plugin: An unknown window message event received. Ignoring.', - ev, - ); - } - - window.addEventListener('message', eventHandler); - abortExecutionRef.current = () => { - contentWindow.postMessage({ - type: IFrameEvents.Incoming.Event.abort, - id, - } satisfies IFrameEvents.Incoming.EventData); - - closedOpenedPrompts(); - - abortExecutionRef.current = null; - }; - - await isFinishedD.promise; - window.removeEventListener('message', eventHandler); - - setState(PreflightWorkerState.ready); - - return result; - } catch (err) { - if (err instanceof Error) { - setLogs(prev => [ - ...prev, - { - level: 'error', - message: err.message, - }, - { - level: 'log', - message: 'Script failed', - }, - { - type: 'separator' as const, - }, - ]); - setState(PreflightWorkerState.ready); - return result; - } - throw err; - } - } - - function abortExecution() { - abortExecutionRef.current?.(); - } - - // terminate worker when leaving laboratory - useEffect( - () => () => { - abortExecutionRef.current?.(); - }, - [], - ); - - return { - execute, - abortExecution, - isEnabled, - setIsEnabled, - content: target?.preflightScript?.sourceCode ?? '', - viewerCanModifyPreflightScript: target?.viewerCanModifyPreflightScript ?? false, - environmentVariables, - setEnvironmentVariables, - state, - logs, - clearLogs: () => setLogs([]), - iframeElement: ( -