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 (
-
- );
-}
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 (
-
- );
-}
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 (
-
- );
-}
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 (
-
- );
-};
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 (
-
- );
-}
diff --git a/packages/web/app/src/components/ui/primitives/floating/shared-styles.ts b/packages/web/app/src/components/ui/primitives/floating/shared-styles.ts
index 185de91b80c..6b104acd129 100644
--- a/packages/web/app/src/components/ui/primitives/floating/shared-styles.ts
+++ b/packages/web/app/src/components/ui/primitives/floating/shared-styles.ts
@@ -98,17 +98,15 @@ export type FloatingProps = {
/**
* Element that triggers the floating panel.
*
- * A function when the trigger has to be composed with another wrapper that owns the same
- * element. `GraphiQLTooltip` in `pages/target-laboratory.tsx` is the case this exists for: it
- * destructures only `{ children, align, side, sideOffset, label }` and forwards nothing, so
- * passing it as an element would swallow the trigger props and the panel would never open.
- * Given a function you apply the props yourself, and any wrapper can sit outside:
+ * A function when the trigger has to be composed with a wrapper that owns the same element
+ * and does not forward unknown props; passed as an element, such a wrapper would swallow the
+ * trigger props and the panel would never open. Given a function you apply the props yourself:
*
* ```tsx
* trigger={props => (
- *
- *
- *
+ *
+ *
+ *
* )}
* ```
*/
diff --git a/packages/web/app/src/components/ui/primitives/form/form.call-sites.preview.tsx b/packages/web/app/src/components/ui/primitives/form/form.call-sites.preview.tsx
index e3b6c61151a..7d6c64417de 100644
--- a/packages/web/app/src/components/ui/primitives/form/form.call-sites.preview.tsx
+++ b/packages/web/app/src/components/ui/primitives/form/form.call-sites.preview.tsx
@@ -94,16 +94,6 @@ import {
CreateTargetFormSchema,
type CreateTargetFormValues,
} from '@/components/target/create-target-form';
-import {
- CollectionForm,
- CollectionFormSchema,
- type CollectionFormValues,
-} from '@/components/target/laboratory/collection-form';
-import {
- OperationForm,
- OperationFormSchema,
- type OperationFormValues,
-} from '@/components/target/laboratory/operation-form';
import {
AppDeploymentProtectionForm,
AppDeploymentProtectionFormSchema,
@@ -156,7 +146,6 @@ import {
ProjectType,
TokenExpirationPeriod,
} from '@/gql/graphql';
-import type { DocumentCollectionOperation } from '@/lib/hooks/laboratory/use-collections';
import { zodResolver } from '@hookform/resolvers/zod';
export const nav: NavPath = 'Primitives/FormControls/Form/Component Examples';
@@ -195,16 +184,6 @@ const ENTRIES = [
what: 'New target, in a dialog',
coveredBy: 'Create',
},
- {
- source: 'components/target/laboratory/collection-form.tsx',
- what: 'New or edited collection, in a dialog',
- coveredBy: 'Create',
- },
- {
- source: 'components/target/laboratory/operation-form.tsx',
- what: 'New or edited operation, in a dialog; creating also picks the collection',
- coveredBy: 'Create',
- },
{
source: 'components/common/slug-form.tsx',
what: 'The organization, project and target slug settings',
@@ -573,58 +552,6 @@ function CreateTargetExample() {
);
}
-function CollectionExample() {
- const [submitted, setSubmitted] = useState(null);
- const form = useForm({
- mode: 'onChange',
- resolver: zodResolver(CollectionFormSchema),
- defaultValues: { name: '', description: '' },
- });
- return (
-
-
-
-
-
-
- );
-}
-
-const COLLECTIONS = [
- { id: 'c1', name: 'Checkout' },
- { id: 'c2', name: 'Smoke tests' },
-] as unknown as DocumentCollectionOperation[];
-
-function OperationExample() {
- const [submitted, setSubmitted] = useState(null);
- const form = useForm({
- mode: 'onChange',
- resolver: zodResolver(OperationFormSchema),
- defaultValues: { name: '', collectionId: '' },
- });
- return (
-
-
-
-
-
-
- );
-}
-
export const Create = createPreview({
label: 'Create',
render: () => (
@@ -632,8 +559,6 @@ export const Create = createPreview({
-
-
),
});
diff --git a/packages/web/app/src/components/ui/prompt.tsx b/packages/web/app/src/components/ui/prompt.tsx
deleted file mode 100644
index b1e751e51c0..00000000000
--- a/packages/web/app/src/components/ui/prompt.tsx
+++ /dev/null
@@ -1,139 +0,0 @@
-import {
- createContext,
- useCallback,
- useContext,
- useState,
- type FC,
- type FormEvent,
- type ReactNode,
-} from 'react';
-import { Button } from '@/components/ui/primitives/button/button';
-import { Input } from '@/components/ui/primitives/input/input';
-import { Dialog } from '@/components/ui/primitives/overlays/dialog/dialog';
-import { useKeepPreviousData } from '@/lib/hooks/use-keep-previous-data';
-
-interface PromptProps {
- id: number;
- onClose: (id: number, value: string | null) => void;
- defaultValue?: string;
-}
-
-function Prompt(props: PromptProps) {
- const [value, setValue] = useState(props.defaultValue || '');
-
- const handleSubmit = (e: FormEvent) => {
- e.preventDefault();
- props.onClose(props.id, value);
- };
-
- return (
-
- );
-}
-
-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 (
-
- );
-}
-
-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 ? (
-