mirror of
https://github.com/AppFlowy-IO/AppFlowy-Web.git
synced 2025-12-02 04:18:14 +08:00
feat: implement drag and drop
This commit is contained in:
260
cypress/e2e/editor/drag_drop_blocks.cy.ts
Normal file
260
cypress/e2e/editor/drag_drop_blocks.cy.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
import { AuthTestUtils } from '../../support/auth-utils';
|
||||
import { waitForReactUpdate } from '../../support/selectors';
|
||||
import { generateRandomEmail } from '../../support/test-config';
|
||||
|
||||
describe('Editor - Drag and Drop Blocks', () => {
|
||||
beforeEach(() => {
|
||||
cy.on('uncaught:exception', (err) => {
|
||||
if (
|
||||
err.message.includes('Minified React error') ||
|
||||
err.message.includes('View not found') ||
|
||||
err.message.includes('No workspace or service found') ||
|
||||
err.message.includes('Cannot resolve a DOM point from Slate point')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
cy.viewport(1280, 720);
|
||||
});
|
||||
|
||||
const dragBlock = (sourceText: string, targetText: string, edge: 'top' | 'bottom') => {
|
||||
cy.log(`Dragging "${sourceText}" to ${edge} of "${targetText}"`);
|
||||
|
||||
// 1. Hover over the source block to reveal controls
|
||||
// Use a selector that works for text-containing blocks AND empty/special blocks if needed
|
||||
// For text blocks, cy.contains works. For others, we might need a more specific selector if sourceText is a selector.
|
||||
const getSource = () => {
|
||||
// Heuristic: if sourceText looks like a selector (starts with [), use get, else contains
|
||||
return sourceText.startsWith('[') ? cy.get(sourceText) : cy.contains(sourceText);
|
||||
};
|
||||
|
||||
getSource().closest('[data-block-type]').scrollIntoView().should('be.visible').click().then(($sourceBlock) => {
|
||||
// Use realHover to simulate user interaction which updates elementFromPoint
|
||||
cy.wrap($sourceBlock).realHover({ position: 'center' });
|
||||
cy.wait(1000); // Wait for hover controls to appear
|
||||
|
||||
// 2. Get the drag handle
|
||||
cy.get('[data-testid="drag-block"]').should('exist').then(($handle) => {
|
||||
const dataTransfer = new DataTransfer();
|
||||
|
||||
// 3. Start dragging
|
||||
cy.wrap($handle).trigger('dragstart', {
|
||||
dataTransfer,
|
||||
force: true,
|
||||
eventConstructor: 'DragEvent'
|
||||
});
|
||||
cy.wait(100);
|
||||
|
||||
// 4. Find target and drop
|
||||
cy.contains(targetText).closest('[data-block-type]').then(($targetBlock) => {
|
||||
const rect = $targetBlock[0].getBoundingClientRect();
|
||||
|
||||
const clientX = rect.left + (rect.width / 2);
|
||||
const clientY = edge === 'top'
|
||||
? rect.top + (rect.height * 0.25)
|
||||
: rect.top + (rect.height * 0.75);
|
||||
|
||||
// Simulate the dragover to trigger the drop indicator
|
||||
cy.wrap($targetBlock).trigger('dragenter', {
|
||||
dataTransfer,
|
||||
clientX,
|
||||
clientY,
|
||||
force: true,
|
||||
eventConstructor: 'DragEvent'
|
||||
});
|
||||
|
||||
cy.wrap($targetBlock).trigger('dragover', {
|
||||
dataTransfer,
|
||||
clientX,
|
||||
clientY,
|
||||
force: true,
|
||||
eventConstructor: 'DragEvent'
|
||||
});
|
||||
|
||||
cy.wait(100); // Wait for drop indicator
|
||||
|
||||
// Drop
|
||||
cy.wrap($targetBlock).trigger('drop', {
|
||||
dataTransfer,
|
||||
clientX,
|
||||
clientY,
|
||||
force: true,
|
||||
eventConstructor: 'DragEvent'
|
||||
});
|
||||
|
||||
// End drag
|
||||
cy.wrap($handle).trigger('dragend', {
|
||||
dataTransfer,
|
||||
force: true,
|
||||
eventConstructor: 'DragEvent'
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
waitForReactUpdate(1000);
|
||||
};
|
||||
|
||||
it('should iteratively reorder items in a list (5 times)', () => {
|
||||
const testEmail = generateRandomEmail();
|
||||
const authUtils = new AuthTestUtils();
|
||||
|
||||
cy.visit('/login', { failOnStatusCode: false });
|
||||
authUtils.signInWithTestUrl(testEmail).then(() => {
|
||||
cy.url({ timeout: 30000 }).should('include', '/app');
|
||||
cy.contains('Getting started').click();
|
||||
|
||||
cy.get('[data-slate-editor="true"]').click().type('{selectall}{backspace}');
|
||||
waitForReactUpdate(500);
|
||||
|
||||
// Create List: 1, 2, 3, 4, 5
|
||||
cy.focused().type('1. Item 1{enter}');
|
||||
cy.focused().type('Item 2{enter}');
|
||||
cy.focused().type('Item 3{enter}');
|
||||
cy.focused().type('Item 4{enter}');
|
||||
cy.focused().type('Item 5{enter}');
|
||||
waitForReactUpdate(1000);
|
||||
|
||||
// Iterate 5 times: Drag first item ("Item 1") to the bottom ("Item 5", then whatever is last)
|
||||
// Actually, to be predictable:
|
||||
// 1. Drag Item 1 to bottom of Item 5. Order: 2, 3, 4, 5, 1
|
||||
// 2. Drag Item 2 to bottom of Item 1. Order: 3, 4, 5, 1, 2
|
||||
// 3. Drag Item 3 to bottom of Item 2. Order: 4, 5, 1, 2, 3
|
||||
// 4. Drag Item 4 to bottom of Item 3. Order: 5, 1, 2, 3, 4
|
||||
// 5. Drag Item 5 to bottom of Item 4. Order: 1, 2, 3, 4, 5 (Back to start!)
|
||||
|
||||
const items = ['Item 1', 'Item 2', 'Item 3', 'Item 4', 'Item 5'];
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const itemToMove = items[i];
|
||||
const targetItem = items[(i + 4) % 5]; // The current last item
|
||||
|
||||
cy.log(`Iteration ${i + 1}: Moving ${itemToMove} below ${targetItem}`);
|
||||
dragBlock(itemToMove, targetItem, 'bottom');
|
||||
}
|
||||
|
||||
// Verify final order (Should be 1, 2, 3, 4, 5)
|
||||
items.forEach((item, index) => {
|
||||
cy.get('[data-block-type="numbered_list"]').eq(index).should('contain.text', item);
|
||||
});
|
||||
|
||||
// Reload and verify
|
||||
cy.reload();
|
||||
cy.get('[data-slate-editor="true"]', { timeout: 30000 }).should('exist');
|
||||
waitForReactUpdate(2000);
|
||||
|
||||
items.forEach((item, index) => {
|
||||
cy.get('[data-block-type="numbered_list"]').eq(index).should('contain.text', item);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should reorder Header and Paragraph blocks', () => {
|
||||
const testEmail = generateRandomEmail();
|
||||
const authUtils = new AuthTestUtils();
|
||||
|
||||
cy.visit('/login', { failOnStatusCode: false });
|
||||
authUtils.signInWithTestUrl(testEmail).then(() => {
|
||||
cy.url({ timeout: 30000 }).should('include', '/app');
|
||||
cy.contains('Getting started').click();
|
||||
|
||||
cy.get('[data-slate-editor="true"]').click().type('{selectall}{backspace}');
|
||||
waitForReactUpdate(500);
|
||||
|
||||
// Create Header
|
||||
cy.focused().type('/');
|
||||
waitForReactUpdate(1000);
|
||||
cy.contains('Heading 1').should('be.visible').click();
|
||||
waitForReactUpdate(500);
|
||||
|
||||
cy.focused().type('Header Block');
|
||||
cy.focused().type('{enter}'); // New line
|
||||
|
||||
// Create Paragraph
|
||||
cy.focused().type('Paragraph Block');
|
||||
waitForReactUpdate(1000);
|
||||
|
||||
// Verify initial order: Header, Paragraph
|
||||
cy.get('[data-block-type="heading"]').should('exist');
|
||||
cy.get('[data-block-type="paragraph"]').should('exist');
|
||||
|
||||
// Drag Header below Paragraph
|
||||
dragBlock('Header Block', 'Paragraph Block', 'bottom');
|
||||
|
||||
// Verify Order: Paragraph, Header
|
||||
cy.get('[data-block-type]').then($blocks => {
|
||||
const textBlocks = $blocks.filter((i, el) =>
|
||||
el.textContent?.includes('Header Block') || el.textContent?.includes('Paragraph Block')
|
||||
);
|
||||
expect(textBlocks[0]).to.contain.text('Paragraph Block');
|
||||
expect(textBlocks[1]).to.contain.text('Header Block');
|
||||
});
|
||||
|
||||
// Reload and verify
|
||||
cy.reload();
|
||||
cy.get('[data-slate-editor="true"]', { timeout: 30000 }).should('exist');
|
||||
waitForReactUpdate(2000);
|
||||
|
||||
cy.get('[data-block-type]').then($blocks => {
|
||||
const textBlocks = $blocks.filter((i, el) =>
|
||||
el.textContent?.includes('Header Block') || el.textContent?.includes('Paragraph Block')
|
||||
);
|
||||
expect(textBlocks[0]).to.contain.text('Paragraph Block');
|
||||
expect(textBlocks[1]).to.contain.text('Header Block');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should reorder Callout block', () => {
|
||||
const testEmail = generateRandomEmail();
|
||||
const authUtils = new AuthTestUtils();
|
||||
|
||||
cy.visit('/login', { failOnStatusCode: false });
|
||||
authUtils.signInWithTestUrl(testEmail).then(() => {
|
||||
cy.url({ timeout: 30000 }).should('include', '/app');
|
||||
cy.contains('Getting started').click();
|
||||
|
||||
cy.get('[data-slate-editor="true"]').click().type('{selectall}{backspace}');
|
||||
waitForReactUpdate(500);
|
||||
|
||||
// Create text blocks first
|
||||
cy.focused().type('Top Text{enter}');
|
||||
cy.focused().type('Bottom Text');
|
||||
waitForReactUpdate(500);
|
||||
|
||||
// Move cursor back to Top Text to insert callout after it
|
||||
cy.contains('Top Text').click().type('{end}{enter}');
|
||||
|
||||
// Create Callout Block
|
||||
cy.focused().type('/callout');
|
||||
waitForReactUpdate(1000);
|
||||
cy.contains('Callout').should('be.visible').click();
|
||||
waitForReactUpdate(1000);
|
||||
|
||||
cy.focused().type('Callout Content');
|
||||
waitForReactUpdate(500);
|
||||
|
||||
// Verify callout block exists
|
||||
cy.get('[data-block-type="callout"]').should('exist');
|
||||
|
||||
// Initial State: Top Text, Callout, Bottom Text
|
||||
// Action: Drag Callout below Bottom Text
|
||||
dragBlock('[data-block-type="callout"]', 'Bottom Text', 'bottom');
|
||||
|
||||
// Verify: Top Text, Bottom Text, Callout
|
||||
cy.get('[data-block-type]').then($blocks => {
|
||||
const relevant = $blocks.filter((i, el) =>
|
||||
el.textContent?.includes('Top Text') ||
|
||||
el.textContent?.includes('Bottom Text') ||
|
||||
el.textContent?.includes('Callout Content')
|
||||
);
|
||||
expect(relevant[0]).to.contain.text('Top Text');
|
||||
expect(relevant[1]).to.contain.text('Bottom Text');
|
||||
expect(relevant[2]).to.contain.text('Callout Content');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
Reference in New Issue
Block a user