import { parse } from '@babel/parser' import { FileType, ProjectPluginStructure } from '@teleporthq/teleport-types' import { RichTextEmbeds, RichTextEmbedsCodegen } from '@teleporthq/teleport-shared' import { NextRichTextEditorProjectPlugin } from '../src/rich-text-editor/project-plugin' import { EMBED_RUNTIME_FILE_NAME } from '../src/rich-content-embeds/runtime-module' import { generateRichTextEditorComponentCode } from '../src/rich-text-editor/component-generator' const EMBED_FORMAT = RichTextEmbeds.EMBED_BLOT_NAME const APP_CONTENT = `import '../style.css' const MyApp = ({ Component, pageProps }) => { return ( ) } export default MyApp ` const editorNode = (formats: string[] | null) => ({ type: 'element', content: { elementType: 'RichTextEditor', semanticType: 'rich-text-editor-node', children: [] as unknown[], attrs: { quillTheme: { type: 'static', content: 'snow' }, ...(formats ? { quillFormats: { type: 'raw', content: JSON.stringify(formats) } } : {}), }, }, }) const makeStructure = (...nodes: unknown[]): ProjectPluginStructure => { const files = new Map() files.set('_app', { path: ['pages'], files: [{ name: '_app', fileType: FileType.JS, content: APP_CONTENT }], }) return { uidl: { name: 'test-project', globals: { settings: { title: 'Test', language: 'en' }, assets: [] }, root: { node: { type: 'element', content: { elementType: 'container', children: [] } } }, components: { 'blog-posts-create': { node: { type: 'element', content: { elementType: 'container', children: nodes } }, }, }, }, files, dependencies: {}, devDependencies: {}, template: { files: [], subFolders: [] }, } as unknown as ProjectPluginStructure } const editorSource = (structure: ProjectPluginStructure): string => (structure.files.get('rich-text-editor-component')?.files[0]?.content as string) ?? '' describe('the embed format on a generated rich-text editor', () => { it('adds the blot, the toolbar entry and the runtime module when a field asks for it', async () => { const structure = await new NextRichTextEditorProjectPlugin().runAfter( makeStructure(editorNode(['bold', 'link', EMBED_FORMAT])) ) const source = editorSource(structure) expect(source).toContain(`from './${EMBED_RUNTIME_FILE_NAME}'`) expect(source).toContain('registerEmbedBlot') expect(source).toContain( `if (formats.includes("${EMBED_FORMAT}")) mediaGroup.push("${EMBED_FORMAT}")` ) expect(source).toContain('EmbedDialog') expect(structure.files.get(EMBED_RUNTIME_FILE_NAME)?.files[0]?.content).toBe( RichTextEmbedsCodegen.generateEmbedRuntimeModuleSource() ) }) it('leaves the editor exactly as before for a project with no embed field', async () => { const structure = await new NextRichTextEditorProjectPlugin().runAfter( makeStructure(editorNode(['bold', 'link'])) ) expect(editorSource(structure)).toBe(generateRichTextEditorComponentCode()) expect(structure.files.has(EMBED_RUNTIME_FILE_NAME)).toBe(false) }) it('treats a field with no explicit formats as not asking for embeds', async () => { const structure = await new NextRichTextEditorProjectPlugin().runAfter( makeStructure(editorNode(null)) ) expect(editorSource(structure)).toBe(generateRichTextEditorComponentCode()) }) it('turns embeds on for the whole editor when any one field asks for them', async () => { const structure = await new NextRichTextEditorProjectPlugin().runAfter( makeStructure(editorNode(['bold']), editorNode(['bold', EMBED_FORMAT])) ) expect(editorSource(structure)).toContain('registerEmbedBlot') }) it('still adds the Quill dependencies and the CSS import', async () => { const structure = await new NextRichTextEditorProjectPlugin().runAfter( makeStructure(editorNode(['bold', EMBED_FORMAT])) ) expect(structure.dependencies['react-quill-new']).toBe('^3.0.0') expect(structure.dependencies.quill).toBe('^2.0.0') expect(structure.files.get('_app')?.files[0]?.content).toContain( "import 'quill/dist/quill.snow.css';" ) }) it('emits an editor that parses in both shapes', () => { expect(() => parse(generateRichTextEditorComponentCode(), { sourceType: 'module', plugins: ['jsx'] }) ).not.toThrow() expect(() => parse(generateRichTextEditorComponentCode({ withEmbeds: true }), { sourceType: 'module', plugins: ['jsx'], }) ).not.toThrow() }) it('imports only helpers the runtime module exports', () => { const source = generateRichTextEditorComponentCode({ withEmbeds: true }) const runtime = RichTextEmbedsCodegen.generateEmbedRuntimeModuleSource() const block = source.match( new RegExp(`import \\{([^}]+)\\} from '\\./${EMBED_RUNTIME_FILE_NAME}'`) ) expect(block).not.toBeNull() const imported = (block as RegExpMatchArray)[1] .split(',') .map((name) => name.trim()) .filter(Boolean) expect(imported.length).toBeGreaterThan(0) imported.forEach((name) => { expect(runtime).toMatch(new RegExp(`^export (?:var|function) ${name}\\b`, 'm')) // An import nothing calls is dead weight in every generated project. expect(source.split(name).length).toBeGreaterThan(2) }) }) it('normalizes the preview back out of the value before onChange sees it', () => { const source = generateRichTextEditorComponentCode({ withEmbeds: true }) expect(source).toContain('normalizeEmbedsInEditorHtml(html)') expect(source).toContain('normalizeEmbedsForStorage(holder)') // The preview is a SIBLING of the block; mounting it inside the frame would // put it in the database. expect(source).toContain('block.appendChild(host)') }) }) /* * Three defects found by driving the generated admin panel in a browser. Each * is invisible in the emitted source unless you know what to look for, and each * breaks the editor in a way that looks like a different bug entirely. */ describe('the generated editor survives being driven', () => { const source = generateRichTextEditorComponentCode({ withEmbeds: true }) const plain = generateRichTextEditorComponentCode() /* * `next/dynamic` attaches a `ref` to its OWN loadable wrapper, so a `ref` on * never reaches the editor: `quillRef.current.getEditor` is * undefined, the insert returns early and the dialog closes having done * nothing at all. */ it('hands the ref to the editor through a prop, since next/dynamic keeps the ref', () => { expect(source).toContain('const QuillWithRef = (props) => {') expect(source).toContain('const { forwardedRef, ...editorProps } = props') expect(source).toContain('') expect(source).toContain('forwardedRef={attachEditor}') // The ref that never arrived must not be written on the dynamic component. expect(source).not.toMatch(/ { expect(source).toContain( "const formatsKey = Array.isArray(formats) ? formats.join('\\u0000') : String(formats)" ) expect(source).toContain('useMemo(() => buildToolbarFromFormats(formats), [formatsKey])') expect(source).toContain( 'const openEmbedDialog = useCallback(() => setEmbedDialogOpen(true), [])' ) expect(source).toContain('}, [toolbar, embedsEnabled, openEmbedDialog])') // An inline handler here is what made the object differ every render. expect(source).not.toMatch(/handlers: \{\s*"tq-embed": \(\) =>/) }) /* * The STORED value has previews and activation markers stripped, so it is * never byte-identical to the html the editor emitted — and react-quill * replaces the whole document whenever the `value` prop differs from what it * last produced. Handing the editor its own html back is what stops that. */ it('feeds the editor its own html when the form echoes the normalized value', () => { expect(source).toContain('const lastEmittedRef = useRef({ raw: null, stored: null })') expect(source).toContain('lastEmittedRef.current = { raw: html, stored: stored }') expect(source).toContain( 'lastEmitted.stored !== null && incoming === lastEmitted.stored ? lastEmitted.raw : incoming' ) expect(source).toContain('value={editorValue}') }) it('mounts previews once the async editor exists, not only when the value changes', () => { expect(source).toContain('const [isEditorReady, setEditorReady] = useState(false)') expect(source).toContain('setEditorReady(!!instance)') expect(source).toContain('}, [embedsEnabled, isEditorReady, value, getEditor])') }) it('leaves an editor without embeds untouched by any of it', () => { expect(plain).not.toContain('forwardedRef') expect(plain).not.toContain('lastEmittedRef') expect(plain).not.toContain('isEditorReady') expect(plain).not.toContain('registerEmbedBlot') // `formatsKey` is deliberately NOT in this list: memoising on the format // names rather than on the array identity is what keeps the toolbar (and // with it the image handler) stable, and both variants need that. }) })