diff --git a/.changeset/remove-graphiql-laboratory.md b/.changeset/remove-graphiql-laboratory.md new file mode 100644 index 00000000000..def3682e73d --- /dev/null +++ b/.changeset/remove-graphiql-laboratory.md @@ -0,0 +1,5 @@ +--- +'hive': minor +--- + +Remove the GraphiQL laboratory tab. The Hive Laboratory is now the only laboratory: it runs the same preflight scripts with a compatible runtime, and on a user's first visit it carries over their GraphiQL environment variables and preflight toggle before clearing the GraphiQL-era browser storage. GraphiQL's own unsaved tabs and run history are not carried over; collections, operations and preflight scripts are stored server-side and are unaffected. The development-only `/dev` GraphiQL page is removed as well. diff --git a/.eslintrc.cjs b/.eslintrc.cjs index fa906726810..5ff7e4a9412 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -261,8 +261,6 @@ module.exports = { 'ring-offset-.*', // Data attribute variants with custom animations (for Radix UI components) 'data-\\[side=(top|right|bottom|left)\\]:animate-slide-(up|down|left|right)-fade', - // GraphiQL classes - 'graphiql-.*', // hive classes 'hive-.*', // Schema diff custom classes (defined in index.css) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b4e6d535339..69d9fd2d356 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -11,10 +11,6 @@ updates: schedule: interval: 'daily' groups: - graphiql: - patterns: - - 'graphiql' - - '@graphiql/*' yoga: patterns: - 'envelop' diff --git a/e2e/helpers/laboratory.ts b/e2e/helpers/laboratory.ts index 77c9ee5b6d1..61bba6b190d 100644 --- a/e2e/helpers/laboratory.ts +++ b/e2e/helpers/laboratory.ts @@ -102,9 +102,6 @@ export function createLaboratoryHelper(page: Page): LaboratoryHelper { const helper: LaboratoryHelper = { async openSeededTarget(slug, search = '') { - await page.addInitScript(() => { - window.localStorage.setItem('hive:laboratory:welcome-dialog-shown', 'true'); - }); await page.goto(`/${slug}/laboratory${search}`, { waitUntil: 'domcontentloaded' }); await expect(page.locator('.hive-laboratory-host')).toBeVisible({ timeout: 30_000 }); }, diff --git a/e2e/specs/laboratory-preflight.spec.ts b/e2e/specs/laboratory-preflight.spec.ts index 736598a3e58..3a02f139430 100644 --- a/e2e/specs/laboratory-preflight.spec.ts +++ b/e2e/specs/laboratory-preflight.spec.ts @@ -135,10 +135,12 @@ test.describe('Laboratory > Preflight', () => { test('carries the GraphiQL env and toggle over on first visit', async ({ page, laboratory }) => { await project.updatePreflightScript({ sourceCode: `console.log('legacy');` }); await page.addInitScript(() => { + window.localStorage.setItem('hive:laboratory:type', 'graphiql'); window.localStorage.setItem('hive:laboratory:environment', '{"foo":"bar","count":2}'); window.localStorage.setItem('hive:laboratory:isPreflightScriptEnabled', 'true'); }); await openWithOperation(laboratory); + expect(await page.evaluate(() => localStorage.getItem('hive:laboratory:type'))).toBeNull(); await expect(page.getByRole('button', { name: 'Disable preflight' })).toBeVisible(); await laboratory.openEnvTab(); diff --git a/package.json b/package.json index 224348ba86b..5acf9011ca5 100644 --- a/package.json +++ b/package.json @@ -217,8 +217,6 @@ "@apollo/federation@0.38.1": "patches/@apollo__federation@0.38.1.patch", "got@16.1.0": "patches/got@16.1.0.patch", "oclif@4.22.65": "patches/oclif@4.22.65.patch", - "graphiql@4.0.0-alpha.5": "patches/graphiql@4.0.0-alpha.5.patch", - "@graphiql/react@1.0.0-alpha.4": "patches/@graphiql__react@1.0.0-alpha.4.patch", "@slonik/pg-driver@48.19.0": "patches/@slonik__pg-driver@48.19.0.patch", "@whatwg-node/fetch@0.12.1": "patches/@whatwg-node__fetch@0.12.1.patch", "@graphql-tools/executor@1.5.0": "patches/@graphql-tools__executor@1.5.0.patch" diff --git a/packages/web/app/docs/DATA.md b/packages/web/app/docs/DATA.md index cfcf13b976c..6a7a87317fa 100644 --- a/packages/web/app/docs/DATA.md +++ b/packages/web/app/docs/DATA.md @@ -225,7 +225,7 @@ src/routes/target/alerts.tsx the gate; activity: default range, loa src/routes/target/apps.tsx loaderDeps on the sort, and on the settled search term; warm beside the gate src/routes/target/proposals.tsx the gate; list and proposal warm + revalidate beside it, loaderDeps on the stage filter and on version + ts -src/routes/target/laboratory.tsx the gate alone +src/routes/target/laboratory.ts the gate alone src/lib/testing/urql.ts createTestClient on cacheOptions; fixtures/ and mocks/ beside it ``` diff --git a/packages/web/app/docs/ROUTER.md b/packages/web/app/docs/ROUTER.md index 85cfaa08b8a..0bc8ceb80bd 100644 --- a/packages/web/app/docs/ROUTER.md +++ b/packages/web/app/docs/ROUTER.md @@ -46,7 +46,7 @@ src/routes/ tree.ts root.addChildren([...]) mirroring every route's getParentRoute root.tsx root route, 404, logout, join anonymous.tsx /auth/* (pathless `anonymous` parent) - authenticated.tsx /, /dev, /manage, /org/new, transfer (pathless `authenticated` parent: the session gate) + authenticated.tsx /, /manage, /org/new, transfer (pathless `authenticated` parent: the session gate) with-header.tsx the header, once, above the routes below (pathless `with-header` parent) oidc-request.tsx $organizationSlug/oidc-request, the OIDC interstitial organization/route.tsx $organizationSlug layout + index, support, subscription diff --git a/packages/web/app/package.json b/packages/web/app/package.json index 5407b6ed365..6a36e3fc657 100644 --- a/packages/web/app/package.json +++ b/packages/web/app/package.json @@ -17,9 +17,6 @@ "@fastify/static": "10.1.5", "@fastify/vite": "9.2.0", "@fontsource-variable/instrument-sans": "5.3.0", - "@graphiql/plugin-explorer": "4.0.0-alpha.2", - "@graphiql/react": "1.0.0-alpha.4", - "@graphiql/toolkit": "0.9.1", "@graphql-hive/laboratory": "workspace:*", "@graphql-inspector/core": "8.0.0", "@graphql-inspector/patch": "catalog:", @@ -29,7 +26,6 @@ "@hookform/resolvers": "5.9.1", "@monaco-editor/react": "catalog:", "@pierre/diffs": "1.5.1", - "@repeaterjs/repeater": "3.1.0", "@sentry/node": "catalog:", "@sentry/react": "11.9.0", "@sentry/vite-plugin": "11.9.0", @@ -44,7 +40,6 @@ "@tanstack/react-virtual": "3.14.13", "@testing-library/dom": "10.4.2", "@testing-library/react": "16.3.3", - "@types/crypto-js": "4.2.2", "@types/js-cookie": "3.0.6", "@types/lodash.debounce": "4.0.9", "@types/react": "catalog:", @@ -55,7 +50,6 @@ "@vitejs/plugin-react": "catalog:", "class-variance-authority": "0.7.1", "clsx": "2.1.1", - "crypto-js": "catalog:", "date-fns": "catalog:", "date-fns-tz": "3.2.0", "diff": "9.0.0", @@ -64,7 +58,6 @@ "echarts": "6.1.0", "esbuild": "catalog:", "fastify": "catalog:", - "graphiql": "4.0.0-alpha.5", "graphql": "catalog:", "graphql-sse": "catalog:", "idb-keyval": "6.3.0", diff --git a/packages/web/app/preflight-worker-embed.html b/packages/web/app/preflight-worker-embed.html deleted file mode 100644 index 8b8a3b00ba3..00000000000 --- a/packages/web/app/preflight-worker-embed.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - I like turtles - Wheatherboi - - - - 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 a0a965f6243..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({ - error: issue => (issue.input === undefined ? 'Collection name is required' : undefined), - }) - .min(2, { - error: 'Collection name must be at least 2 characters long', - }) - .max(50, { - error: '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 b4bb6dfa8f5..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 a43110547fd..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-testid': '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 047e39de9a9..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-testid': '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 e55888e188f..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-testid': '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 f0183d8e8f8..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-testid': '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 91b4673f562..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-testid')).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 ddb2e8b455c..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({ - error: issue => (issue.input === undefined ? 'Operation name is required' : undefined), - }) - .min(3, { - error: 'Operation name must be at least 3 characters long', - }) - .max(50, { - error: 'Operation name must be less than 50 characters long', - }), - collectionId: z.string({ - error: issue => (issue.input === undefined ? 'Collection is required' : undefined), - }), -}); - -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-testid': '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/components/ui/user-menu.tsx b/packages/web/app/src/components/ui/user-menu.tsx index d95007f86da..d768b7cdea9 100644 --- a/packages/web/app/src/components/ui/user-menu.tsx +++ b/packages/web/app/src/components/ui/user-menu.tsx @@ -14,7 +14,6 @@ import { } from 'lucide-react'; import { useMutation, useQuery } from 'urql'; import { useThemeMenuEntry } from '@/components/theme/theme-switcher'; -import { GraphQLIcon } from '@/components/ui/brand-icon'; import { Avatar } from '@/components/ui/primitives/avatar/avatar'; import { Menu } from '@/components/ui/primitives/floating/menu/menu'; import { AlertDialog } from '@/components/ui/primitives/overlays/alert-dialog/alert-dialog'; @@ -218,11 +217,6 @@ export function UserMenu({ withOrganization = true }: { withOrganization?: boole icon: TrendingUpIcon, render: , }, - env.nodeEnv === 'development' && { - label: 'Dev GraphiQL', - icon: GraphQLIcon, - render: , - }, ], [ currentOrganization?.me?.canLeaveOrganization && { diff --git a/packages/web/app/src/index.css b/packages/web/app/src/index.css index f5dab2f08cf..eac13070aa1 100644 --- a/packages/web/app/src/index.css +++ b/packages/web/app/src/index.css @@ -480,10 +480,6 @@ } } -.hive-badge-is-changed:after { - @apply absolute right-2 size-1.5 rounded-full border border-b-accent bg-accent-muted content-['']; -} - /* * Thin, muted scrollbar. The standard properties cover Firefox, Chromium 121+ and Safari 18.2+; * the -webkit- rules cover older Chromium and Safari, which ignore the standard ones. Browsers diff --git a/packages/web/app/src/lib/hooks/laboratory/use-collections.ts b/packages/web/app/src/lib/hooks/laboratory/use-collections.ts deleted file mode 100644 index 16250b57e7a..00000000000 --- a/packages/web/app/src/lib/hooks/laboratory/use-collections.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { useEffect } from 'react'; -import { useQuery } from 'urql'; -import { useToast } from '@/components/ui/primitives/toast/toast'; -import { graphql } from '@/gql'; -import { CollectionsQuery as _CollectionsQuery } from '@/gql/graphql'; - -export const CollectionsQuery = graphql(` - query Collections($selector: TargetSelectorInput!) { - target(reference: { bySelector: $selector }) { - id - documentCollections { - edges { - cursor - node { - id - name - description - operations(first: 100) { - edges { - node { - id - name - } - cursor - } - } - } - } - } - } - } -`); - -export type DocumentCollectionOperation = Exclude< - _CollectionsQuery['target'], - null | undefined ->['documentCollections']['edges'][number]['node']; - -const EMPTY_ARRAY: DocumentCollectionOperation[] = []; - -export function useCollections(props: { - organizationSlug: string; - projectSlug: string; - targetSlug: string; -}): { - fetching: boolean; - collections: DocumentCollectionOperation[]; -} { - const [{ data, error, fetching }] = useQuery({ - query: CollectionsQuery, - variables: { - selector: { - targetSlug: props.targetSlug, - organizationSlug: props.organizationSlug, - projectSlug: props.projectSlug, - }, - }, - }); - - const { toast } = useToast(); - - useEffect(() => { - if (error) { - toast({ variant: 'destructive', title: error.message }); - } - }, [error]); - - return { - collections: data?.target?.documentCollections.edges.map(v => v.node) || EMPTY_ARRAY, - fetching, - }; -} diff --git a/packages/web/app/src/lib/hooks/laboratory/use-current-operation.ts b/packages/web/app/src/lib/hooks/laboratory/use-current-operation.ts index a088a871a5a..c281d0f574e 100644 --- a/packages/web/app/src/lib/hooks/laboratory/use-current-operation.ts +++ b/packages/web/app/src/lib/hooks/laboratory/use-current-operation.ts @@ -22,28 +22,6 @@ const OperationQuery = graphql(` } `); -export function useCurrentOperation(props: { - organizationSlug: string; - projectSlug: string; - targetSlug: string; -}) { - const operationIdFromSearch = useOperationFromQueryString(); - const [{ data }] = useQuery({ - query: OperationQuery, - variables: { - selector: { - targetSlug: props.targetSlug, - projectSlug: props.projectSlug, - organizationSlug: props.organizationSlug, - }, - id: operationIdFromSearch!, - }, - pause: !operationIdFromSearch, - }); - // if operationId is undefined `data` could contain previous state - return operationIdFromSearch ? data?.target?.documentCollectionOperation : null; -} - export function useCurrentOperationWithFetchingState(props: { organizationSlug: string; projectSlug: string; 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 821e3112574..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-testid': 'collection-item' }, - triggerAttrs: { 'data-testid': 'collection-item-trigger' }, - action: shouldShowMenu ? ( - - - - } - sections={[ - [ - { - label: 'Add operation', - trailingIcon: PlusIcon, - onClick: () => void addOperation(collection.id), - disabled: createOperationState.fetching, - attrs: { 'data-testid': 'add-operation-to-collection' }, - }, - ], - [ - { - label: 'Edit', - onClick: () => { - setCollectionId(collection.id); - toggleCollectionModal(); - }, - attrs: { 'data-testid': 'edit-collection' }, - }, - { - label: 'Delete', - variant: 'destructiveAction', - onClick: () => { - setCollectionId(collection.id); - toggleDeleteCollectionModalOpen(); - }, - attrs: { 'data-testid': '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-testid': 'copy-operation-link' }, - }, - ], - [ - canEdit && { - label: 'Edit', - onClick: () => setOperationToEditId(node.id), - attrs: { 'data-testid': 'edit-operation' }, - }, - canDelete && { - label: 'Delete', - variant: 'destructiveAction' as const, - onClick: () => { - setOperationToDeleteId(node.id); - toggleDeleteOperationModalOpen(); - }, - attrs: { 'data-testid': '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/lib/hooks/laboratory/use-sync-operation-state.ts b/packages/web/app/src/lib/hooks/laboratory/use-sync-operation-state.ts deleted file mode 100644 index cde1d9b5c05..00000000000 --- a/packages/web/app/src/lib/hooks/laboratory/use-sync-operation-state.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { useCurrentOperation } from './use-current-operation'; - -export function useSyncOperationState(props: { - organizationSlug: string; - projectSlug: string; - targetSlug: string; -}): { - savedOperation: { query: string; variables: string; updatedAt: number } | null; - setSavedOperation: (value: { query: string; variables: string }) => void; - clearOperation: () => void; -} { - const currentOperation = useCurrentOperation({ - organizationSlug: props.organizationSlug, - projectSlug: props.projectSlug, - targetSlug: props.targetSlug, - }); - const storageKey = currentOperation ? `hive:operation-${currentOperation.id}` : null; - const savedOperationData = storageKey ? localStorage.getItem(storageKey) : null; - - return { - savedOperation: savedOperationData ? JSON.parse(savedOperationData) : null, - setSavedOperation(value: { query: string; variables: string }) { - if (storageKey) { - localStorage.setItem(storageKey, JSON.stringify({ ...value, updatedAt: Date.now() })); - } - }, - clearOperation() { - if (storageKey) { - localStorage.removeItem(storageKey); - } - }, - }; -} diff --git a/packages/web/app/src/lib/laboratory-legacy-storage.spec.ts b/packages/web/app/src/lib/laboratory-legacy-storage.spec.ts index 4aeb560fb23..46b3a940504 100644 --- a/packages/web/app/src/lib/laboratory-legacy-storage.spec.ts +++ b/packages/web/app/src/lib/laboratory-legacy-storage.spec.ts @@ -27,7 +27,7 @@ describe('migrateLegacyLaboratoryStorage', () => { expect(JSON.parse(localStorage.getItem(ENV)!)).toEqual({ variables: { token: 'abc', retries: '3', debug: 'true', none: 'null' }, }); - expect(localStorage.getItem(OLD_ENV)).not.toBeNull(); + expect(localStorage.getItem(OLD_ENV)).toBeNull(); }); it.each([ @@ -83,6 +83,25 @@ describe('migrateLegacyLaboratoryStorage', () => { expect(localStorage.getItem(ENABLED)).toBe('false'); }); + it('clears every key the GraphiQL tab left behind and keeps the rest', () => { + localStorage.setItem('hive:laboratory:type', 'graphiql'); + localStorage.setItem('hive:laboratory:welcome-dialog-shown', 'true'); + localStorage.setItem(OLD_ENABLED, 'true'); + localStorage.setItem('hive:operation-abc', '{"query":"{ a }"}'); + localStorage.setItem('graphiql:tabState', '{"tabs":[]}'); + localStorage.setItem('graphiql:queries', '[]'); + localStorage.setItem('hive:laboratory:operations', '[]'); + localStorage.setItem('hive-theme', 'dark'); + + migrateLegacyLaboratoryStorage(); + + expect(dump()).toEqual([ + ['hive-theme', 'dark'], + ['hive:laboratory:operations', '[]'], + [ENABLED, 'true'], + ]); + }); + it('changes nothing on a second run', () => { localStorage.setItem(OLD_ENV, '{"token":"abc"}'); localStorage.setItem(OLD_ENABLED, 'true'); diff --git a/packages/web/app/src/lib/laboratory-legacy-storage.ts b/packages/web/app/src/lib/laboratory-legacy-storage.ts index 7ae412919c8..d01db641042 100644 --- a/packages/web/app/src/lib/laboratory-legacy-storage.ts +++ b/packages/web/app/src/lib/laboratory-legacy-storage.ts @@ -4,9 +4,18 @@ const OLD_ENV_KEY = 'hive:laboratory:environment'; const OLD_ENABLED_KEY = 'hive:laboratory:isPreflightScriptEnabled'; const ENV_KEY = 'hive:laboratory:env'; const ENABLED_KEY = 'hive:laboratory:preflightEnabled'; +// Everything else the GraphiQL tab left behind: its page switch, its welcome dialog, its drafts +// and GraphiQL's own store. +const LEGACY_KEYS = [ + 'hive:laboratory:type', + 'hive:laboratory:welcome-dialog-shown', + OLD_ENV_KEY, + OLD_ENABLED_KEY, +]; +const LEGACY_PREFIXES = ['hive:operation-', 'graphiql:']; -// The GraphiQL tab kept env as raw editor text and the toggle as a JSON boolean. Writes only -// when the lab key is missing; the old keys stay until that tab is removed. +// The GraphiQL tab kept env as raw editor text and the toggle as a JSON boolean. Copies only +// when the lab key is missing, then clears what the removed tab left behind. export function migrateLegacyLaboratoryStorage() { if (!localStorage.getItem(ENV_KEY)) { const variables = legacyEnvVariables(localStorage.getItem(OLD_ENV_KEY)); @@ -23,6 +32,18 @@ export function migrateLegacyLaboratoryStorage() { localStorage.setItem(ENABLED_KEY, enabled); } } + + for (const key of legacyKeys()) { + localStorage.removeItem(key); + } +} + +function legacyKeys() { + const keys = Array.from({ length: localStorage.length }, (_, i) => localStorage.key(i) ?? ''); + + return keys.filter( + key => LEGACY_KEYS.includes(key) || LEGACY_PREFIXES.some(prefix => key.startsWith(prefix)), + ); } function legacyEnvVariables(raw: string | null): Record | null { 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 1b523c7d08b..00000000000 --- a/packages/web/app/src/lib/preflight/graphiql-plugin.tsx +++ /dev/null @@ -1,880 +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: ( -