Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
288 changes: 288 additions & 0 deletions cypress/e2e/editor/blocks/unsupported_block.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,288 @@
import { AuthTestUtils } from '../../../support/auth-utils';
import { waitForReactUpdate } from '../../../support/selectors';
import { generateRandomEmail } from '../../../support/test-config';

describe('Unsupported Block Display', () => {
const authUtils = new AuthTestUtils();
const testEmail = generateRandomEmail();

before(() => {
cy.viewport(1280, 720);
});

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') ||
// Slate editor errors when DOM doesn't match Slate state during testing
err.message.includes('Cannot resolve a DOM point from Slate point') ||
err.message.includes('Cannot resolve a DOM node from Slate node') ||
// React errors during dynamic block injection
err.message.includes('Invalid hook call')
) {
return false;
}

return true;
});

cy.session(
testEmail,
() => {
authUtils.signInWithTestUrl(testEmail);
},
{
validate: () => {
cy.window().then((win) => {
const token = win.localStorage.getItem('af_auth_token');

expect(token).to.be.ok;
});
},
}
);

cy.visit('/app');
cy.url({ timeout: 30000 }).should('include', '/app');
cy.contains('Getting started', { timeout: 10000 }).should('be.visible').click();
cy.wait(2000);

// Ensure any open menus are closed
cy.get('body').type('{esc}');

cy.get('[data-slate-editor="true"]').should('exist').click({ force: true });
cy.focused().type('{selectall}{backspace}');
waitForReactUpdate(500);
});

describe('Unsupported Block Rendering', () => {
it('should display unsupported block message for unknown block types', () => {
// Wait for editor to be ready
waitForReactUpdate(500);

// Insert an unsupported block type via the exposed Yjs document
cy.window().then((win) => {
const testWindow = win as Window & {
__TEST_DOC__?: {
getMap: (key: string) => unknown;
transact: (fn: () => void) => void;
};
Y?: {
Map: new () => Map<string, unknown>;
Text: new () => unknown;
Array: new <T>() => { push: (items: T[]) => void };
};
};

const doc = testWindow.__TEST_DOC__;
const Y = testWindow.Y;

if (!doc || !Y) {
throw new Error('Test utilities not found. Ensure app is running in dev mode.');
}

// Get the document structure
// Structure: doc.getMap('data').get('document') -> { blocks, meta, page_id }
const sharedRoot = doc.getMap('data') as Map<string, unknown>;
const document = sharedRoot.get('document') as Map<string, unknown>;
const blocks = document.get('blocks') as Map<string, unknown>;
const meta = document.get('meta') as Map<string, unknown>;
const pageId = document.get('page_id') as string;
const childrenMap = meta.get('children_map') as Map<string, unknown>;
const textMap = meta.get('text_map') as Map<string, unknown>;

// Generate a unique block ID
const blockId = `test_unsupported_${Date.now()}`;

// Insert an unsupported block type
doc.transact(() => {
const block = new Y.Map();

block.set('id', blockId);
block.set('ty', 'future_block_type_not_yet_implemented'); // Unknown block type
block.set('children', blockId);
block.set('external_id', blockId);
block.set('external_type', 'text');
block.set('parent', pageId);
block.set('data', '{}');

(blocks as Map<string, unknown>).set(blockId, block);

// Add to page children
const pageChildren = childrenMap.get(pageId) as { push: (items: string[]) => void };

if (pageChildren) {
pageChildren.push([blockId]);
}

// Create empty text for the block
const blockText = new Y.Text();

(textMap as Map<string, unknown>).set(blockId, blockText);

// Create empty children array
const blockChildren = new Y.Array<string>();

(childrenMap as Map<string, unknown>).set(blockId, blockChildren);
});
});

waitForReactUpdate(1000);

// Verify the unsupported block component is rendered
cy.get('[data-testid="unsupported-block"]').should('exist');
cy.get('[data-testid="unsupported-block"]').should('be.visible');

// Verify it shows the correct message
cy.get('[data-testid="unsupported-block"]')
.should('contain.text', 'not supported yet')
.and('contain.text', 'future_block_type_not_yet_implemented');
});

it('should display warning icon and block type name', () => {
// Insert an unsupported block with a specific type name
const testBlockType = 'my_custom_unknown_block';

cy.window().then((win) => {
const testWindow = win as Window & {
__TEST_DOC__?: {
getMap: (key: string) => unknown;
transact: (fn: () => void) => void;
};
Y?: {
Map: new () => Map<string, unknown>;
Text: new () => unknown;
Array: new <T>() => { push: (items: T[]) => void };
};
};

const doc = testWindow.__TEST_DOC__;
const Y = testWindow.Y;

if (!doc || !Y) {
throw new Error('Test utilities not found. Ensure app is running in dev mode.');
}

// Structure: doc.getMap('data').get('document') -> { blocks, meta, page_id }
const sharedRoot = doc.getMap('data') as Map<string, unknown>;
const document = sharedRoot.get('document') as Map<string, unknown>;
const blocks = document.get('blocks') as Map<string, unknown>;
const meta = document.get('meta') as Map<string, unknown>;
const pageId = document.get('page_id') as string;
const childrenMap = meta.get('children_map') as Map<string, unknown>;
const textMap = meta.get('text_map') as Map<string, unknown>;

const blockId = `test_${Date.now()}`;

doc.transact(() => {
const block = new Y.Map();

block.set('id', blockId);
block.set('ty', testBlockType);
block.set('children', blockId);
block.set('external_id', blockId);
block.set('external_type', 'text');
block.set('parent', pageId);
block.set('data', '{}');

(blocks as Map<string, unknown>).set(blockId, block);

const pageChildren = childrenMap.get(pageId) as { push: (items: string[]) => void };

if (pageChildren) {
pageChildren.push([blockId]);
}

const blockText = new Y.Text();

(textMap as Map<string, unknown>).set(blockId, blockText);

const blockChildren = new Y.Array<string>();

(childrenMap as Map<string, unknown>).set(blockId, blockChildren);
});
});

waitForReactUpdate(1000);

// Verify the unsupported block shows the type name
cy.get('[data-testid="unsupported-block"]')
.should('be.visible')
.and('contain.text', testBlockType);

// Verify it has the warning styling (contains an SVG icon)
cy.get('[data-testid="unsupported-block"] svg').should('exist');
});

it('should be non-editable', () => {
// Insert an unsupported block
cy.window().then((win) => {
const testWindow = win as Window & {
__TEST_DOC__?: {
getMap: (key: string) => unknown;
transact: (fn: () => void) => void;
};
Y?: {
Map: new () => Map<string, unknown>;
Text: new () => unknown;
Array: new <T>() => { push: (items: T[]) => void };
};
};

const doc = testWindow.__TEST_DOC__;
const Y = testWindow.Y;

if (!doc || !Y) {
throw new Error('Test utilities not found.');
}

// Structure: doc.getMap('data').get('document') -> { blocks, meta, page_id }
const sharedRoot = doc.getMap('data') as Map<string, unknown>;
const document = sharedRoot.get('document') as Map<string, unknown>;
const blocks = document.get('blocks') as Map<string, unknown>;
const meta = document.get('meta') as Map<string, unknown>;
const pageId = document.get('page_id') as string;
const childrenMap = meta.get('children_map') as Map<string, unknown>;
const textMap = meta.get('text_map') as Map<string, unknown>;

const blockId = `test_readonly_${Date.now()}`;

doc.transact(() => {
const block = new Y.Map();

block.set('id', blockId);
block.set('ty', 'readonly_test_block');
block.set('children', blockId);
block.set('external_id', blockId);
block.set('external_type', 'text');
block.set('parent', pageId);
block.set('data', '{}');

(blocks as Map<string, unknown>).set(blockId, block);

const pageChildren = childrenMap.get(pageId) as { push: (items: string[]) => void };

if (pageChildren) {
pageChildren.push([blockId]);
}

const blockText = new Y.Text();

(textMap as Map<string, unknown>).set(blockId, blockText);

const blockChildren = new Y.Array<string>();

(childrenMap as Map<string, unknown>).set(blockId, blockChildren);
});
});

waitForReactUpdate(1000);

// Verify the unsupported block has contentEditable=false
cy.get('[data-testid="unsupported-block"]')
.should('have.attr', 'contenteditable', 'false');
});
});
});
25 changes: 25 additions & 0 deletions src/components/editor/CollaborativeEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,34 @@ function CollaborativeEditor({
setIsConnected(true);
onEditorConnected?.(editor);

// Expose editor and doc for E2E testing in development/test mode
if (import.meta.env.DEV || import.meta.env.MODE === 'test') {
const testWindow = window as Window & {
__TEST_EDITOR__?: YjsEditor;
__TEST_DOC__?: Y.Doc;
Y?: typeof Y;
};

testWindow.__TEST_EDITOR__ = editor;
testWindow.__TEST_DOC__ = doc;
testWindow.Y = Y; // Expose Yjs module for creating test blocks
}

return () => {
console.debug('disconnect');
editor.disconnect();
// Clean up test references
if (import.meta.env.DEV || import.meta.env.MODE === 'test') {
const testWindow = window as Window & {
__TEST_EDITOR__?: YjsEditor;
__TEST_DOC__?: Y.Doc;
Y?: typeof Y;
};

delete testWindow.__TEST_EDITOR__;
delete testWindow.__TEST_DOC__;
// Keep Y exposed as it might be needed for other editors
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editor]);
Expand Down
57 changes: 28 additions & 29 deletions src/components/editor/components/element/BlockNotFound.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,39 +4,38 @@ import { forwardRef } from 'react';
import { UnSupportedBlock } from '@/components/editor/components/element/UnSupportedBlock';
import { EditorElementProps } from '@/components/editor/editor.type';

export const BlockNotFound = forwardRef<HTMLDivElement, EditorElementProps>(({ node, children }, ref) => {
export const BlockNotFound = forwardRef<HTMLDivElement, EditorElementProps>(({ node, children, ...attributes }, ref) => {
const type = node.type;

if (import.meta.env.DEV) {
if (type === 'block_not_found') {
return (
<div
className={'w-full my-1 select-none'}
ref={ref}
contentEditable={false}
// Special case for blocks that reference deleted/moved blocks (dev only)
if (import.meta.env.DEV && type === 'block_not_found') {
return (
<div
className={'my-1 w-full select-none'}
ref={ref}
contentEditable={false}
>
<Alert
className={'h-fit w-full'}
severity={'error'}
>
<Alert
className={'h-fit w-full'}
severity={'error'}
>
<div className={'text-base'}>{`Block not found, id is ${node.blockId}`}</div>
<div>
{'It might be deleted or moved to another place but the children map is still referencing it.'}
</div>
</Alert>
</div>
);
}
<div className={'text-base'}>{`Block not found, id is ${node.blockId}`}</div>
<div>
{'It might be deleted or moved to another place but the children map is still referencing it.'}
</div>
</Alert>
</div>
);
}

return <UnSupportedBlock
// Show unsupported block component for all unknown block types
return (
<UnSupportedBlock
ref={ref}
node={node}
>{children}</UnSupportedBlock>;
}

return <div
className={'w-full h-0 select-none'}
ref={ref}
contentEditable={false}
/>;
{...attributes}
>
{children}
</UnSupportedBlock>
);
});
Loading
Loading