diff --git a/cypress/e2e/editor/blocks/unsupported_block.cy.ts b/cypress/e2e/editor/blocks/unsupported_block.cy.ts new file mode 100644 index 000000000..bb4b8abee --- /dev/null +++ b/cypress/e2e/editor/blocks/unsupported_block.cy.ts @@ -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; + Text: new () => unknown; + Array: new () => { 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; + const document = sharedRoot.get('document') as Map; + const blocks = document.get('blocks') as Map; + const meta = document.get('meta') as Map; + const pageId = document.get('page_id') as string; + const childrenMap = meta.get('children_map') as Map; + const textMap = meta.get('text_map') as Map; + + // 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).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).set(blockId, blockText); + + // Create empty children array + const blockChildren = new Y.Array(); + + (childrenMap as Map).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; + Text: new () => unknown; + Array: new () => { 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; + const document = sharedRoot.get('document') as Map; + const blocks = document.get('blocks') as Map; + const meta = document.get('meta') as Map; + const pageId = document.get('page_id') as string; + const childrenMap = meta.get('children_map') as Map; + const textMap = meta.get('text_map') as Map; + + 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).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).set(blockId, blockText); + + const blockChildren = new Y.Array(); + + (childrenMap as Map).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; + Text: new () => unknown; + Array: new () => { 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; + const document = sharedRoot.get('document') as Map; + const blocks = document.get('blocks') as Map; + const meta = document.get('meta') as Map; + const pageId = document.get('page_id') as string; + const childrenMap = meta.get('children_map') as Map; + const textMap = meta.get('text_map') as Map; + + 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).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).set(blockId, blockText); + + const blockChildren = new Y.Array(); + + (childrenMap as Map).set(blockId, blockChildren); + }); + }); + + waitForReactUpdate(1000); + + // Verify the unsupported block has contentEditable=false + cy.get('[data-testid="unsupported-block"]') + .should('have.attr', 'contenteditable', 'false'); + }); + }); +}); diff --git a/src/components/editor/CollaborativeEditor.tsx b/src/components/editor/CollaborativeEditor.tsx index b3928f92c..d3690c293 100644 --- a/src/components/editor/CollaborativeEditor.tsx +++ b/src/components/editor/CollaborativeEditor.tsx @@ -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]); diff --git a/src/components/editor/components/element/BlockNotFound.tsx b/src/components/editor/components/element/BlockNotFound.tsx index 3d8bd9ae4..fd0ba21d4 100644 --- a/src/components/editor/components/element/BlockNotFound.tsx +++ b/src/components/editor/components/element/BlockNotFound.tsx @@ -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(({ node, children }, ref) => { +export const BlockNotFound = forwardRef(({ node, children, ...attributes }, ref) => { const type = node.type; - if (import.meta.env.DEV) { - if (type === 'block_not_found') { - return ( -
+ - -
{`Block not found, id is ${node.blockId}`}
-
- {'It might be deleted or moved to another place but the children map is still referencing it.'} -
-
-
- ); - } +
{`Block not found, id is ${node.blockId}`}
+
+ {'It might be deleted or moved to another place but the children map is still referencing it.'} +
+ + + ); + } - return {children}; - } - - return
; + {...attributes} + > + {children} + + ); }); diff --git a/src/components/editor/components/element/UnSupportedBlock.stories.tsx b/src/components/editor/components/element/UnSupportedBlock.stories.tsx new file mode 100644 index 000000000..4389cf348 --- /dev/null +++ b/src/components/editor/components/element/UnSupportedBlock.stories.tsx @@ -0,0 +1,154 @@ +import { withContainer } from '../../../../../.storybook/decorators'; + +import { UnSupportedBlock } from './UnSupportedBlock'; + +import type { Meta, StoryObj } from '@storybook/react-vite'; + +/** + * The UnSupportedBlock component is displayed when the editor encounters a block type + * that is not yet supported. This provides a user-friendly message instead of breaking + * the editor or showing nothing. + * + * The component shows: + * - A warning icon + * - The unsupported block type name + * - In development mode: a collapsible debug section with the full block JSON + */ +const meta = { + title: 'Editor/UnSupportedBlock', + component: UnSupportedBlock, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + decorators: [withContainer({ padding: '20px', maxWidth: '800px' })], + argTypes: { + node: { + description: 'The block node with an unsupported type', + control: 'object', + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** + * Default unsupported block with a generic unknown type + */ +export const Default: Story = { + args: { + node: { + type: 'unknown_block_type', + blockId: 'block-123', + children: [], + }, + }, +}; + +/** + * An unsupported block with a future feature type name + */ +export const FutureFeature: Story = { + args: { + node: { + type: 'ai_generated_content', + blockId: 'block-456', + children: [], + data: { + prompt: 'Generate a summary', + model: 'gpt-4', + }, + }, + }, +}; + +/** + * An unsupported block representing a deprecated feature + */ +export const DeprecatedFeature: Story = { + args: { + node: { + type: 'legacy_embed_block', + blockId: 'block-789', + children: [], + data: { + embedUrl: 'https://example.com/embed', + width: 640, + height: 480, + }, + }, + }, +}; + +/** + * An unsupported block with a complex nested data structure + */ +export const ComplexData: Story = { + args: { + node: { + type: 'custom_plugin_block', + blockId: 'block-complex', + children: [], + data: { + pluginId: 'my-custom-plugin', + version: '2.0.0', + config: { + enabled: true, + options: ['option1', 'option2'], + nested: { + level1: { + level2: { + value: 'deep nested data', + }, + }, + }, + }, + }, + }, + }, +}; + +/** + * An unsupported block with minimal data + */ +export const MinimalData: Story = { + args: { + node: { + type: 'simple_unsupported', + blockId: 'block-minimal', + children: [], + }, + }, +}; + +/** + * An unsupported block with a very long type name + */ +export const LongTypeName: Story = { + args: { + node: { + type: 'this_is_a_very_long_block_type_name_that_might_overflow_the_container', + blockId: 'block-long', + children: [], + }, + }, +}; + +/** + * An unsupported block representing a third-party integration + */ +export const ThirdPartyIntegration: Story = { + args: { + node: { + type: 'notion_import_block', + blockId: 'block-notion', + children: [], + data: { + source: 'notion', + originalId: 'abc123', + importedAt: '2024-01-15T10:30:00Z', + }, + }, + }, +}; diff --git a/src/components/editor/components/element/UnSupportedBlock.tsx b/src/components/editor/components/element/UnSupportedBlock.tsx index 4953ff5b6..dce77090c 100644 --- a/src/components/editor/components/element/UnSupportedBlock.tsx +++ b/src/components/editor/components/element/UnSupportedBlock.tsx @@ -1,9 +1,42 @@ import { Alert } from '@mui/material'; import { forwardRef } from 'react'; +import { ReactComponent as WarningIcon } from '@/assets/icons/warning.svg'; import { EditorElementProps } from '@/components/editor/editor.type'; +import { cn } from '@/lib/utils'; -export const UnSupportedBlock = forwardRef(({ node, children }, ref) => { +export const UnSupportedBlock = forwardRef(({ node, children, className, ...attributes }, ref) => { + const isDev = import.meta.env.DEV; + + return ( +
+ + + This block type “{node.type}” is not supported yet + + {isDev && ( +
+ Debug +
+            {JSON.stringify(node, null, 2)}
+          
+
+ )} + {children} +
+ ); +}); + +export const UnSupportedBlockDev = forwardRef(({ node, children }, ref) => { return (