Skip to content

Commit

Permalink
Fix editor state and operation handling in Laboratory + e2e tests (#6282
Browse files Browse the repository at this point in the history
)

Fixes

When opening a new tab or selecting one of the saved operations a wrong query was populated. It was always the default query or the one that's active. Meaning, you couldn't select and see the saved operation :)

When saving the operation, the submit button of the form was always disabled, even when the state of the form was valid.

e2e tests

Added tests for CRUD of collections and their operations.
The scenario where a user visits a shared link to an operation is also now tested.
  • Loading branch information
kamilkisiela authored Jan 9, 2025
1 parent 469443b commit a7f9d50
Show file tree
Hide file tree
Showing 14 changed files with 350 additions and 38 deletions.
7 changes: 7 additions & 0 deletions .changeset/young-peas-battle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'hive': patch
---

Fix editor state and operation handling in Laboratory.

When opening a new tab or selecting a saved operation, the editor incorrectly populated the query, defaulting to the active query. This made it impossible to view the selected operation. Additionally, the submit button for saving an operation was always disabled, even when the form was in a valid state.
1 change: 1 addition & 0 deletions .eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ module.exports = {
extends: 'plugin:cypress/recommended',
rules: {
'cypress/no-unnecessary-waiting': 'off',
'cypress/unsafe-to-chain-command': 'off',
},
},
],
Expand Down
248 changes: 248 additions & 0 deletions cypress/e2e/laboratory-collections.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
import { laboratory } from '../support/testkit';

beforeEach(() => {
cy.clearAllLocalStorage().then(() => {
return cy.task('seedTarget').then(({ slug, refreshToken }: any) => {
cy.setCookie('sRefreshToken', refreshToken);

cy.visit(`/${slug}/laboratory`);

// To make sure the operation collections tab is opened
// It first opens the Documentation Explorer or GraphiQL Explorer,
// then opens the Operation Collections tab.
// It does that, because the Operation Collections tab could be already opened for some reason.
// This way it's guaranteed that the Operation Collections tab is opened.
cy.get('[aria-label*="Show Documentation Explorer"], [aria-label*="Show GraphiQL Explorer"]')
.first()
.click();
cy.get('[aria-label="Show Operation Collections"]').click();
});
});
});

const collections = {
/**
* Opens the modal to create a new collection and fills the form
*/
create(args: { name: string; description: string }) {
cy.get('button[data-cy="new-collection"]').click();
cy.get('div[data-cy="create-collection-modal"] input[name="name"]').type(args.name);
cy.get('div[data-cy="create-collection-modal"] input[name="description"]').type(
args.description,
);
cy.get('div[data-cy="create-collection-modal"] button[type="submit"]').click();
},
/**
* Clicks on a collection in the sidebar
*/
clickCollectionButton(name: string) {
cy.get('button[data-cy="collection-item-trigger"]').contains(name).click();
},
/**
* Saves the current operation as a new operation and assigns it to a collection
*/
saveCurrentOperationAs(args: { name: string; collectionName: string }) {
cy.get('[data-cy="save-operation"]').click();
cy.get('[data-cy="save-operation-as"]').click();

cy.get('div[data-cy="create-operation-modal"] input[name="name"]').type(args.name);
cy.get(
'div[data-cy="create-operation-modal"] button[data-cy="collection-select-trigger"]',
).click();

cy.get('div[data-cy="collection-select-item"]').contains(args.collectionName).click();
cy.get('div[data-cy="create-operation-modal"] button[type="submit"]').click();
},
/**
* Opens the menu for a specific operation, to access delete, copy link or edit buttons.
*/
openOperationMenu(name: string) {
return cy.get(`a[data-cy="operation-${name}"] ~ button`).click();
},
/**
* Opens the menu for a specific collection, to access delete or edit buttons.
*/
openCollectionMenu(name: string) {
return cy
.contains(`[data-cy="collection-item-trigger"]`, name)
.parent()
.get('[data-cy="collection-menu-trigger"]')
.click();
},
/**
* Returns the operation element
*/
getOperationButton(name: string) {
return cy.get<HTMLAnchorElement>(`a[data-cy="operation-${name}"]`);
},
/**
* Returns the collection element
*/
getCollectionButton(name: string) {
return cy.contains('[data-cy="collection-item"]', name);
},
};

describe('Laboratory > Collections', () => {
it('create a collection and an operation', () => {
collections.create({
name: 'collection-1',
description: 'Description 1',
});
laboratory.updateEditorValue(`query op1 { test }`);
collections.saveCurrentOperationAs({
name: 'operation-1',
collectionName: 'collection-1',
});
collections.getOperationButton('operation-1').should('exist');
});

it(`edit collection's name`, () => {
collections.create({
name: 'collection-1',
description: 'Description 1',
});
laboratory.updateEditorValue(`query op1 { test }`);
collections.saveCurrentOperationAs({
name: 'operation-1',
collectionName: 'collection-1',
});

collections.openCollectionMenu('collection-1');
// Click on the edit button and fill the form
cy.get('[data-cy="edit-collection"]').click();
cy.get('[data-cy="create-collection-modal"]').should('exist');
cy.get('[data-cy="create-collection-modal"] input[name="name"]')
.clear()
.type('collection-1-updated');
cy.get('[data-cy="create-collection-modal"] button[data-cy="confirm"]').click();

collections.getCollectionButton('collection-1-updated').should('exist');
collections.getOperationButton('operation-1').should('exist');
});

it('delete a collection', () => {
collections.create({
name: 'collection-1',
description: 'Description 1',
});
laboratory.updateEditorValue(`query op1 { test }`);
collections.saveCurrentOperationAs({
name: 'operation-1',
collectionName: 'collection-1',
});

collections.getOperationButton('operation-1').should('exist');
collections.getCollectionButton('collection-1').should('exist');

collections.openCollectionMenu('collection-1');
// Click on the delete button and confirm the deletion
cy.get('[data-cy="delete-collection"]').click();
cy.get('[data-cy="delete-collection-modal"]').should('exist');
cy.get('[data-cy="delete-collection-modal"] button[data-cy="confirm"]').click();

collections.getOperationButton('operation-1').should('not.exist');
collections.getCollectionButton('collection-1').should('not.exist');
});

it(`edit operation's name`, () => {
collections.create({
name: 'collection-1',
description: 'Description 1',
});
laboratory.updateEditorValue(`query op1 { test }`);
collections.saveCurrentOperationAs({
name: 'operation-1',
collectionName: 'collection-1',
});

collections.openOperationMenu('operation-1');
// Click on the edit button and fill the form
cy.get('[data-cy="edit-operation"]').click();
cy.get('[data-cy="edit-operation-modal"]').should('exist');
cy.get('[data-cy="edit-operation-modal"] input[name="name"]').type('operation-1-updated');
cy.get('[data-cy="edit-operation-modal"] button[data-cy="confirm"]').click();

collections.getOperationButton('operation-1').should('not.exist');
collections.getOperationButton('operation-1-updated').should('exist');
});

it('delete an operation', () => {
collections.create({
name: 'collection-1',
description: 'Description 1',
});
laboratory.updateEditorValue(`query op1 { test }`);
collections.saveCurrentOperationAs({
name: 'operation-1',
collectionName: 'collection-1',
});

laboratory.openNewTab();
laboratory.updateEditorValue(`query op2 { test }`);
collections.saveCurrentOperationAs({
name: 'operation-2',
collectionName: 'collection-1',
});

collections.openOperationMenu('operation-1');
// Click on the delete button and confirm the deletion
cy.get('[data-cy="delete-operation"]').click();
cy.get('[data-cy="delete-operation-modal"]').should('exist');
cy.get('[data-cy="delete-operation-modal"] button[data-cy="confirm"]').click();

collections.getOperationButton('operation-1').should('not.exist');
collections.getOperationButton('operation-2').should('exist');
});

it('visiting a copied operation link should open the operation', () => {
collections.create({
name: 'collection-1',
description: 'Description 1',
});
collections.create({
name: 'collection-2',
description: 'Description 2',
});
collections.clickCollectionButton('collection-1');
laboratory.updateEditorValue(`query op1 { test }`);
collections.saveCurrentOperationAs({
name: 'operation-1',
collectionName: 'collection-1',
});

laboratory.openNewTab();
laboratory.updateEditorValue(`query op2 { test }`);
collections.saveCurrentOperationAs({
name: 'operation-2',
collectionName: 'collection-2',
});

collections.openOperationMenu('operation-1');

// Stub the clipboard API to intercept the copied URL
cy.window().then(win => {
cy.stub(win.navigator.clipboard, 'writeText').as('copied');
});

cy.get('[data-cy="copy-operation-link"]').click();

cy.get<{
getCall(index: number): {
args: unknown[];
};
}>('@copied')
.should('have.been.calledOnce')
.then(stub => {
const copiedUrl = stub.getCall(0).args[0]; // Extract the copied URL
if (typeof copiedUrl !== 'string') {
throw new Error('The copied URL is not a string');
}
// Navigate to the copied URL
return cy.visit(copiedUrl);
});

laboratory.assertActiveTab('operation-1');
laboratory.getEditorValue().should('contain', 'op1');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ function setEditorScript(script: string) {
setMonacoEditorContents('preflight-script-editor', script);
}

describe('Preflight Script', () => {
describe('Laboratory > Preflight Script', () => {
it('mini script editor is read only', () => {
cy.dataCy('toggle-preflight-script').click();
// Wait loading disappears
Expand Down
34 changes: 34 additions & 0 deletions cypress/support/testkit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,40 @@ export function createProject(projectSlug: string) {
cy.get('form[data-cy="create-project-form"] [data-cy="submit"]').click();
}

export const laboratory = {
/**
* Updates the value of the graphiql editor
*/
updateEditorValue(value: string) {
cy.get('.graphiql-query-editor .cm-s-graphiql').then($editor => {
const editor = ($editor[0] as any).CodeMirror; // Access the CodeMirror instance
editor.setValue(value);
});
},
/**
* Returns the value of the graphiql editor as Chainable<string>
*/
getEditorValue() {
return cy.get('.graphiql-query-editor .cm-s-graphiql').then<string>($editor => {
const editor = ($editor[0] as any).CodeMirror; // Access the CodeMirror instance
return editor.getValue();
});
},
openNewTab() {
cy.get('button[aria-label="New tab"]').click();
// tab's title should be "untitled" as it's a default name
cy.contains('button[aria-controls="graphiql-session"]', 'untitled').should('exist');
},
/**
* Asserts that the tab with the given name is active
*/
assertActiveTab(name: string) {
cy.contains('li.graphiql-tab-active > button[aria-controls="graphiql-session"]', name).should(
'exist',
);
},
};

export function dedent(strings: TemplateStringsArray, ...values: unknown[]): string {
// Took from https://github.com/dmnd/dedent
// Couldn't use the package because I had some issues with moduleResolution.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,10 @@ export function CreateCollectionModalContent(props: {
}) {
return (
<Dialog open={props.isOpen} onOpenChange={props.toggleModalOpen}>
<DialogContent className="container w-4/5 max-w-[600px] md:w-3/5">
<DialogContent
className="container w-4/5 max-w-[600px] md:w-3/5"
data-cy="create-collection-modal"
>
{!props.fetching && (
<Form {...props.form}>
<form className="space-y-8" onSubmit={props.form.handleSubmit(props.onSubmit)}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,10 @@ export function CreateOperationModalContent(props: {
props.form.reset();
}}
>
<DialogContent className="container w-4/5 max-w-[600px] md:w-3/5">
<DialogContent
className="container w-4/5 max-w-[600px] md:w-3/5"
data-cy="create-operation-modal"
>
{!props.fetching && (
<Form {...props.form}>
<form className="space-y-8" onSubmit={props.form.handleSubmit(props.onSubmit)}>
Expand Down Expand Up @@ -224,13 +227,13 @@ export function CreateOperationModalContent(props: {
</FormLabel>
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectTrigger data-cy="collection-select-trigger">
{props.collections.find(c => c.id === field.value)?.name ??
'Select a Collection'}
</SelectTrigger>
<SelectContent className="w-[--radix-select-trigger-width]">
{props.collections.map(c => (
<SelectItem key={c.id} value={c.id}>
<SelectItem key={c.id} value={c.id} data-cy="collection-select-item">
{c.name}
<div className="mt-1 line-clamp-1 text-xs opacity-50">
{c.description}
Expand Down Expand Up @@ -263,12 +266,7 @@ export function CreateOperationModalContent(props: {
size="lg"
className="w-full justify-center"
variant="primary"
disabled={
props.form.formState.isSubmitting ||
!props.form.formState.isValid ||
!props.form.getValues('collectionId')
}
data-cy="confirm"
disabled={props.form.formState.isSubmitting || !props.form.formState.isValid}
>
Add Operation
</Button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ export function DeleteCollectionModalContent(props: {
}) {
return (
<Dialog open={props.isOpen} onOpenChange={props.toggleModalOpen}>
<DialogContent className="w-4/5 max-w-[520px] md:w-3/5">
<DialogContent className="w-4/5 max-w-[520px] md:w-3/5" data-cy="delete-collection-modal">
<DialogHeader>
<DialogTitle>Delete Collection</DialogTitle>
<DialogDescription>Are you sure you wish to delete this collection?</DialogDescription>
Expand All @@ -108,7 +108,7 @@ export function DeleteCollectionModalContent(props: {
>
Cancel
</Button>
<Button variant="destructive" onClick={props.handleDelete}>
<Button variant="destructive" data-cy="confirm" onClick={props.handleDelete}>
Delete
</Button>
</DialogFooter>
Expand Down
Loading

0 comments on commit a7f9d50

Please sign in to comment.