Browse Source

Add missing unit tests for Draftail JS code

Thibaud Colas 7 years ago
parent
commit
ad2ec0b601

+ 49 - 0
client/src/components/Draftail/__snapshots__/index.test.js.snap

@@ -0,0 +1,49 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Draftail #initEditor options 1`] = `
+Object {
+  "autoCapitalize": null,
+  "autoComplete": null,
+  "autoCorrect": null,
+  "blockTypes": Array [],
+  "decorators": Array [],
+  "enableHorizontalRule": Object {
+    "description": "Horizontal line",
+  },
+  "enableLineBreak": Object {
+    "description": "Line break",
+  },
+  "entityTypes": Array [
+    Object {
+      "block": [Function],
+      "decorator": undefined,
+      "source": [Function],
+      "type": "Image",
+    },
+  ],
+  "inlineStyles": Array [],
+  "maxListNesting": 4,
+  "onSave": [Function],
+  "placeholder": "Write here…",
+  "rawContentState": null,
+  "showRedoControl": Object {
+    "description": "Redo",
+  },
+  "showUndoControl": Object {
+    "description": "Undo",
+  },
+  "spellCheck": true,
+  "stateSaveInterval": 250,
+  "stripPastedStyles": false,
+  "textAlignment": null,
+  "textDirectionality": null,
+}
+`;
+
+exports[`Draftail #wrapWagtailIcon works 1`] = `
+<Icon
+  className={null}
+  name="media"
+  title={null}
+/>
+`;

+ 1 - 1
client/src/components/Draftail/blocks/ImageBlock.js

@@ -21,7 +21,7 @@ class ImageBlock extends Component {
     const { editorState, onChange } = blockProps;
 
     const data = {
-      alt: e.currentTarget.value,
+      alt: e.target.value,
     };
 
     onChange(DraftUtils.updateBlockEntity(editorState, block, data));

+ 10 - 5
client/src/components/Draftail/blocks/ImageBlock.test.js

@@ -65,8 +65,7 @@ describe('ImageBlock', () => {
     ).toMatchSnapshot();
   });
 
-  // Alt field is readonly for now.
-  it.skip('changeAlt', () => {
+  it('changeAlt', () => {
     jest.spyOn(DraftUtils, 'updateBlockEntity');
     DraftUtils.updateBlockEntity.mockImplementation(e => e);
 
@@ -88,11 +87,17 @@ describe('ImageBlock', () => {
       />
     );
 
-    wrapper.find('[type="text"]').simulate('change', {
-      currentTarget: {
+    // // Alt field is readonly for now.
+    wrapper.instance().changeAlt({
+      target: {
         value: 'new alt',
-      },
+      }
     });
+    // wrapper.find('[type="text"]').simulate('change', {
+    //   target: {
+    //     value: 'new alt',
+    //   },
+    // });
 
     expect(onChange).toHaveBeenCalled();
     expect(DraftUtils.updateBlockEntity).toHaveBeenCalledWith(

+ 1 - 1
client/src/components/Draftail/blocks/MediaBlock.test.js

@@ -86,7 +86,7 @@ describe('MediaBlock', () => {
       ).toMatchSnapshot();
     });
 
-    it.skip('large viewport', () => {
+    it('large viewport', () => {
       const target = document.createElement('div');
       document.body.appendChild(target);
       target.getBoundingClientRect = () => ({

+ 2 - 2
client/src/components/Draftail/decorators/TooltipEntity.js

@@ -61,7 +61,7 @@ class TooltipEntity extends Component {
             closeOnResize
           >
             <Tooltip target={showTooltipAt} direction="top">
-              {url ? (
+              {(
                 <a
                   href={url}
                   title={url}
@@ -71,7 +71,7 @@ class TooltipEntity extends Component {
                 >
                   {shortenLabel(label)}
                 </a>
-              ) : null}
+              )}
 
               <button
                 className="button Tooltip__button"

+ 85 - 0
client/src/components/Draftail/decorators/TooltipEntity.test.js

@@ -0,0 +1,85 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import TooltipEntity from './TooltipEntity';
+
+describe('TooltipEntity', () => {
+  it('works', () => {
+    expect(shallow((
+      <TooltipEntity
+        entityKey="1"
+        onEdit={() => {}}
+        onRemove={() => {}}
+        icon="#icon-test"
+        url="https://www.example.com/"
+        label="www.example.com"
+      >
+        test
+      </TooltipEntity>
+    ))).toMatchSnapshot();
+  });
+
+  it('shortened label', () => {
+    expect(shallow((
+      <TooltipEntity
+        entityKey="1"
+        onEdit={() => {}}
+        onRemove={() => {}}
+        icon="#icon-test"
+        url="https://www.example.com/"
+        label="www.example.example.example.com"
+      >
+        test
+      </TooltipEntity>
+    )).setState({
+      showTooltipAt: document.createElement('div').getBoundingClientRect(),
+    }).find('Tooltip a')
+      .text()).toBe('www.example.example.…');
+  });
+
+  it('#openTooltip', () => {
+    const wrapper = shallow((
+      <TooltipEntity
+        entityKey="1"
+        onEdit={() => {}}
+        onRemove={() => {}}
+        icon="#icon-test"
+        url="https://www.example.com/"
+        label="www.example.com"
+      >
+        test
+      </TooltipEntity>
+    ));
+
+    wrapper.find('.TooltipEntity').simulate('mouseup', {
+      target: document.createElement('div'),
+    });
+
+    expect(wrapper).toMatchSnapshot();
+  });
+
+  it('#closeTooltip', () => {
+    const wrapper = shallow((
+      <TooltipEntity
+        entityKey="1"
+        onEdit={() => {}}
+        onRemove={() => {}}
+        icon="#icon-test"
+        url="https://www.example.com/"
+        label="www.example.com"
+      >
+        test
+      </TooltipEntity>
+    ));
+
+    wrapper.find('.TooltipEntity').simulate('mouseup', {
+      target: document.createElement('div'),
+    });
+
+    wrapper.instance().closeTooltip();
+
+    expect(wrapper.state()).toEqual({
+      showTooltipAt: null,
+    });
+  });
+});

+ 73 - 0
client/src/components/Draftail/decorators/__snapshots__/TooltipEntity.test.js.snap

@@ -0,0 +1,73 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`TooltipEntity #openTooltip 1`] = `
+<a
+  className="TooltipEntity"
+  onMouseUp={[Function]}
+  role="button"
+>
+  <Icon
+    className="TooltipEntity__icon"
+    icon="#icon-test"
+    title={null}
+  />
+  test
+  <Portal
+    closeOnClick={true}
+    closeOnResize={true}
+    closeOnType={true}
+    onClose={[Function]}
+  >
+    <Tooltip
+      direction="top"
+      target={
+        Object {
+          "bottom": 0,
+          "height": 0,
+          "left": 0,
+          "right": 0,
+          "top": 0,
+          "width": 0,
+        }
+      }
+    >
+      <a
+        className="Tooltip__link"
+        href="https://www.example.com/"
+        rel="noopener noreferrer"
+        target="_blank"
+        title="https://www.example.com/"
+      >
+        www.example.com
+      </a>
+      <button
+        className="button Tooltip__button"
+        onClick={[Function]}
+      >
+        Edit
+      </button>
+      <button
+        className="button button-secondary no Tooltip__button"
+        onClick={[Function]}
+      >
+        Remove
+      </button>
+    </Tooltip>
+  </Portal>
+</a>
+`;
+
+exports[`TooltipEntity works 1`] = `
+<a
+  className="TooltipEntity"
+  onMouseUp={[Function]}
+  role="button"
+>
+  <Icon
+    className="TooltipEntity__icon"
+    icon="#icon-test"
+    title={null}
+  />
+  test
+</a>
+`;

+ 28 - 52
client/src/components/Draftail/index.js

@@ -6,16 +6,18 @@ import { IS_IE11, STRINGS } from '../../config/wagtailConfig';
 
 import Icon from '../Icon/Icon';
 
-import Link from './decorators/Link';
-import Document from './decorators/Document';
-import ImageBlock from './blocks/ImageBlock';
-import EmbedBlock from './blocks/EmbedBlock';
+import registry from './registry';
 
-import ModalWorkflowSource from './sources/ModalWorkflowSource';
+export { registry };
 
-import registry from './registry';
+export { default as Link } from './decorators/Link';
+export { default as Document } from './decorators/Document';
+export { default as ImageBlock } from './blocks/ImageBlock';
+export { default as EmbedBlock } from './blocks/EmbedBlock';
+
+export { default as ModalWorkflowSource } from './sources/ModalWorkflowSource';
 
-const wrapWagtailIcon = type => {
+export const wrapWagtailIcon = type => {
   const isIconFont = type.icon && typeof type.icon === 'string';
   if (isIconFont) {
     return Object.assign(type, {
@@ -26,7 +28,12 @@ const wrapWagtailIcon = type => {
   return type;
 };
 
-export const initEditor = (fieldName, options = {}) => {
+/**
+ * Initialises the DraftailEditor for a given field.
+ * @param {string} fieldName
+ * @param {Object} options
+ */
+export const initEditor = (fieldName, options) => {
   const field = document.querySelector(`[name="${fieldName}"]`);
   const editorWrapper = document.createElement('div');
   field.parentNode.appendChild(editorWrapper);
@@ -35,29 +42,19 @@ export const initEditor = (fieldName, options = {}) => {
     field.value = JSON.stringify(rawContentState);
   };
 
-  let blockTypes;
-  let inlineStyles;
-  let entityTypes;
-
-  if (options && options.blockTypes) {
-    blockTypes = options.blockTypes.map(wrapWagtailIcon);
-  }
-
-  if (options && options.inlineStyles) {
-    inlineStyles = options.inlineStyles.map(wrapWagtailIcon);
-  }
+  const blockTypes = options.blockTypes || [];
+  const inlineStyles = options.inlineStyles || [];
+  let entityTypes = options.entityTypes || [];
 
-  if (options && options.entityTypes) {
-    entityTypes = options.entityTypes.map(wrapWagtailIcon).map(type =>
-      Object.assign(type, {
-        source: registry.getSource(type.source),
-        decorator: registry.getDecorator(type.decorator),
-        block: registry.getBlock(type.block),
-      })
-    );
-  }
+  entityTypes = entityTypes.map(wrapWagtailIcon).map(type =>
+    Object.assign(type, {
+      source: registry.getSource(type.source),
+      decorator: registry.getDecorator(type.decorator),
+      block: registry.getBlock(type.block),
+    })
+  );
 
-  const enableHorizontalRule = options && options.enableHorizontalRule ? {
+  const enableHorizontalRule = options.enableHorizontalRule ? {
     description: STRINGS.HORIZONTAL_LINE,
   } : false;
 
@@ -77,8 +74,8 @@ export const initEditor = (fieldName, options = {}) => {
       // Draft.js + IE 11 presents some issues with pasting rich text. Disable rich paste there.
       stripPastedStyles={IS_IE11}
       {...options}
-      blockTypes={blockTypes}
-      inlineStyles={inlineStyles}
+      blockTypes={blockTypes.map(wrapWagtailIcon)}
+      inlineStyles={inlineStyles.map(wrapWagtailIcon)}
       entityTypes={entityTypes}
       enableHorizontalRule={enableHorizontalRule}
     />
@@ -89,24 +86,3 @@ export const initEditor = (fieldName, options = {}) => {
   // Bind editor instance to its field so it can be accessed imperatively elsewhere.
   field.draftailEditor = draftailEditor;
 };
-
-registry.registerSources({
-  ModalWorkflowSource,
-});
-registry.registerDecorators({
-  Link,
-  Document,
-});
-registry.registerBlocks({
-  ImageBlock,
-  EmbedBlock,
-});
-
-const draftail = Object.assign(
-  {
-    initEditor,
-  },
-  registry
-);
-
-export default draftail;

+ 89 - 0
client/src/components/Draftail/index.test.js

@@ -0,0 +1,89 @@
+import {
+  wrapWagtailIcon,
+  initEditor,
+  registry,
+  ModalWorkflowSource,
+  Link,
+  Document,
+  ImageBlock,
+  EmbedBlock,
+} from './index';
+
+describe('Draftail', () => {
+  describe('#initEditor', () => {
+    beforeEach(() => {
+      document.body.innerHTML = '';
+    });
+
+    it('works', () => {
+      const field = document.createElement('input');
+      field.name = 'test';
+      field.value = 'null';
+      document.body.appendChild(field);
+
+      initEditor('test', {});
+
+      expect(field.draftailEditor).toBeDefined();
+    });
+
+    it('onSave', () => {
+      const field = document.createElement('input');
+      field.name = 'test';
+      field.value = 'null';
+      document.body.appendChild(field);
+
+      initEditor('test', {});
+
+      field.draftailEditor.saveState();
+
+      expect(field.value).toBe('null');
+    });
+
+    it('options', () => {
+      const field = document.createElement('input');
+      field.name = 'test';
+      field.value = 'null';
+      document.body.appendChild(field);
+
+      registry.registerSources({
+        ModalWorkflowSource: () => {},
+      });
+
+      registry.registerBlocks({
+        ImageBlock: () => {},
+      });
+
+      initEditor('test', {
+        entityTypes: [
+          { type: 'Image', source: 'ModalWorkflowSource', block: 'ImageBlock' },
+        ],
+        enableHorizontalRule: true,
+      });
+
+      expect(field.draftailEditor.props).toMatchSnapshot();
+    });
+  });
+
+  describe('#wrapWagtailIcon', () => {
+    it('works', () => {
+      expect(wrapWagtailIcon({ icon: 'media' }).icon).toMatchSnapshot();
+    });
+
+    it('no icon', () => {
+      const type = {};
+      expect(wrapWagtailIcon(type)).toBe(type);
+    });
+
+    it('array icon', () => {
+      const type = { icon: ['M10 10 H 90 V 90 H 10 Z'] };
+      expect(wrapWagtailIcon(type)).toBe(type);
+    });
+  });
+
+  it('#registry', () => expect(registry).toBeDefined());
+  it('#ModalWorkflowSource', () => expect(ModalWorkflowSource).toBeDefined());
+  it('#Link', () => expect(Link).toBeDefined());
+  it('#Document', () => expect(Document).toBeDefined());
+  it('#ImageBlock', () => expect(ImageBlock).toBeDefined());
+  it('#EmbedBlock', () => expect(EmbedBlock).toBeDefined());
+});

+ 5 - 15
client/src/components/Draftail/registry.js

@@ -4,29 +4,19 @@ const registry = {
   sources: {},
 };
 
-const registerDecorators = (decorators) => {
-  Object.assign(registry.decorators, decorators);
-};
+const registerDecorators = (decorators) => Object.assign(registry.decorators, decorators);
+const registerBlocks = (blocks) => Object.assign(registry.blocks, blocks);
+const registerSources = (sources) => Object.assign(registry.sources, sources);
 
 const getDecorator = name => registry.decorators[name];
-
-const registerBlocks = (blocks) => {
-  Object.assign(registry.blocks, blocks);
-};
-
 const getBlock = name => registry.blocks[name];
-
-const registerSources = (sources) => {
-  Object.assign(registry.sources, sources);
-};
-
 const getSource = name => registry.sources[name];
 
 export default {
   registerDecorators,
-  getDecorator,
   registerBlocks,
-  getBlock,
   registerSources,
+  getDecorator,
+  getBlock,
   getSource,
 };

+ 39 - 0
client/src/components/Draftail/registry.test.js

@@ -0,0 +1,39 @@
+import registry from './registry';
+
+describe('registry', () => {
+  describe('sources', () => {
+    it('works', () => {
+      expect(registry.getSource('UndefinedSource')).not.toBeDefined();
+
+      registry.registerSources({
+        TestSource: null,
+      });
+
+      expect(registry.getSource('TestSource')).toBe(null);
+    });
+  });
+
+  describe('decorators', () => {
+    it('works', () => {
+      expect(registry.getDecorator('UndefinedDecorator')).not.toBeDefined();
+
+      registry.registerDecorators({
+        TestDecorator: null,
+      });
+
+      expect(registry.getDecorator('TestDecorator')).toBe(null);
+    });
+  });
+
+  describe('blocks', () => {
+    it('works', () => {
+      expect(registry.getBlock('UndefinedBlock')).not.toBeDefined();
+
+      registry.registerBlocks({
+        TestBlock: null,
+      });
+
+      expect(registry.getBlock('TestBlock')).toBe(null);
+    });
+  });
+});

+ 2 - 2
client/src/components/Draftail/sources/ModalWorkflowSource.js

@@ -16,7 +16,7 @@ MUTABILITY[DOCUMENT] = 'MUTABLE';
 MUTABILITY[ENTITY_TYPE.IMAGE] = 'IMMUTABLE';
 MUTABILITY[EMBED] = 'IMMUTABLE';
 
-const getChooserConfig = (entityType, entity) => {
+export const getChooserConfig = (entityType, entity) => {
   const chooserURL = {};
   chooserURL[ENTITY_TYPE.IMAGE] = `${global.chooserUrls.imageChooser}?select_format=true`;
   chooserURL[EMBED] = global.chooserUrls.embedsChooser;
@@ -59,7 +59,7 @@ const getChooserConfig = (entityType, entity) => {
   };
 };
 
-const filterEntityData = (entityType, data) => {
+export const filterEntityData = (entityType, data) => {
   switch (entityType.type) {
   case ENTITY_TYPE.IMAGE:
     return {

+ 343 - 0
client/src/components/Draftail/sources/ModalWorkflowSource.test.js

@@ -0,0 +1,343 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+
+import ModalWorkflowSource, { getChooserConfig, filterEntityData } from './ModalWorkflowSource';
+import { EditorState, convertFromRaw, AtomicBlockUtils, RichUtils, Modifier } from 'draft-js';
+
+global.ModalWorkflow = () => {};
+
+describe('ModalWorkflowSource', () => {
+  beforeEach(() => {
+    jest.spyOn(global, 'ModalWorkflow');
+  });
+
+  afterEach(() => {
+    jest.restoreAllMocks();
+  });
+
+  it('works', () => {
+    expect(shallow((
+      <ModalWorkflowSource
+        editorState={{}}
+        entityType={{}}
+        entity={{}}
+        onComplete={() => {}}
+        onClose={() => {}}
+      />
+    ))).toMatchSnapshot();
+  });
+
+  describe('#getChooserConfig', () => {
+    it('IMAGE', () => {
+      expect(getChooserConfig({ type: 'IMAGE' })).toEqual({
+        url: '/admin/images/chooser/?select_format=true',
+        urlParams: {},
+      });
+    });
+
+    it('EMBED', () => {
+      expect(getChooserConfig({ type: 'EMBED' })).toEqual({
+        url: '/admin/embeds/chooser/',
+        urlParams: {},
+      });
+    });
+
+    it('DOCUMENT', () => {
+      expect(getChooserConfig({ type: 'DOCUMENT' })).toEqual({
+        url: '/admin/documents/chooser/',
+        urlParams: {},
+      });
+    });
+
+    describe('LINK', () => {
+      it('no entity', () => {
+        expect(getChooserConfig({ type: 'LINK' })).toMatchSnapshot();
+      });
+
+      it('page', () => {
+        expect(getChooserConfig({ type: 'LINK' }, {
+          getData: () => ({ id: 1, parentId: 0 })
+        })).toMatchSnapshot();
+      });
+
+      it('mail', () => {
+        expect(getChooserConfig({ type: 'LINK' }, {
+          getData: () => ({ url: 'mailto:test@example.com' })
+        })).toMatchSnapshot();
+      });
+
+      it('external', () => {
+        expect(getChooserConfig({ type: 'LINK' }, {
+          getData: () => ({ url: 'https://www.example.com/' })
+        })).toMatchSnapshot();
+      });
+    });
+  });
+
+  describe('#filterEntityData', () => {
+    it('IMAGE', () => {
+      expect(filterEntityData({ type: 'IMAGE' }, {
+        id: 53,
+        title: 'Test',
+        alt: 'Test',
+        class: 'richtext-image right',
+        edit_link: '/admin/images/53/',
+        format: 'right',
+        preview: {
+          url: '/media/images/test.width-500.jpg',
+        }
+      })).toMatchSnapshot();
+    });
+
+    it('EMBED', () => {
+      expect(filterEntityData({ type: 'EMBED' }, {
+        authorName: 'Test',
+        embedType: 'video',
+        providerName: 'YouTube',
+        thumbnail: 'https://i.ytimg.com/vi/pSlVtxLOYiM/hqdefault.jpg',
+        title: 'Test',
+        url: 'https://www.youtube.com/watch?v=pSlVtxLOYiM',
+      })).toMatchSnapshot();
+    });
+
+    it('DOCUMENT', () => {
+      expect(filterEntityData({ type: 'DOCUMENT' }, {
+        edit_link: '/admin/documents/edit/1/',
+        filename: 'test.pdf',
+        id: 1,
+        title: 'Test',
+        url: '/documents/1/test.pdf',
+      })).toMatchSnapshot();
+    });
+
+    it('OTHER', () => {
+      expect(filterEntityData({ type: 'OTHER' }, {})).toEqual({});
+    });
+
+    describe('LINK', () => {
+      it('page', () => {
+        expect(filterEntityData({ type: 'LINK' }, {
+          id: 60,
+          parentId: 1,
+          url: '/',
+          editUrl: '/admin/pages/60/edit/',
+          title: 'Welcome to the Wagtail Bakery!',
+        })).toMatchSnapshot();
+      });
+
+      it('mail', () => {
+        expect(filterEntityData({ type: 'LINK' }, {
+          prefer_this_title_as_link_text: false,
+          title: 'test@example.com',
+          url: 'mailto:test@example.com',
+        })).toMatchSnapshot();
+      });
+
+      it('external', () => {
+        expect(filterEntityData({ type: 'LINK' }, {
+          prefer_this_title_as_link_text: false,
+          title: 'https://www.example.com/',
+          url: 'https://www.example.com/',
+        })).toMatchSnapshot();
+      });
+    });
+  });
+
+  it('#componentDidMount', () => {
+    const wrapper = shallow((
+      <ModalWorkflowSource
+        editorState={{}}
+        entityType={{}}
+        entity={{}}
+        onComplete={() => {}}
+        onClose={() => {}}
+      />
+    ));
+
+    wrapper.instance().onChosen = jest.fn();
+
+    wrapper.instance().componentDidMount();
+
+    global.ModalWorkflow.mock.calls[0][0].responses.embedChosen('test', {});
+
+    expect(global.ModalWorkflow).toHaveBeenCalled();
+    expect(global.jQuery().on).toHaveBeenCalled();
+    expect(wrapper.instance().onChosen).toHaveBeenCalled();
+  });
+
+  it('#onError', () => {
+    window.alert = jest.fn();
+    const onClose = jest.fn();
+
+    const wrapper = shallow((
+      <ModalWorkflowSource
+        editorState={{}}
+        entityType={{}}
+        entity={{}}
+        onComplete={() => {}}
+        onClose={onClose}
+      />
+    ));
+
+    wrapper.instance().componentDidMount();
+
+    global.ModalWorkflow.mock.calls[0][0].onError();
+
+    expect(global.ModalWorkflow).toHaveBeenCalled();
+    expect(global.jQuery().on).toHaveBeenCalled();
+    expect(window.alert).toHaveBeenCalled();
+    expect(onClose).toHaveBeenCalled();
+  });
+
+  it('#componentWillUnmount', () => {
+    const wrapper = shallow((
+      <ModalWorkflowSource
+        editorState={{}}
+        entityType={{}}
+        entity={{}}
+        onComplete={() => {}}
+        onClose={() => {}}
+      />
+    ));
+
+    wrapper.instance().componentWillUnmount();
+
+    expect(global.jQuery().off).toHaveBeenCalled();
+  });
+
+  describe('#onChosen', () => {
+    it('works', () => {
+      jest.spyOn(RichUtils, 'toggleLink');
+
+      const onComplete = jest.fn();
+
+      let editorState = EditorState.createWithContent(convertFromRaw({
+        entityMap: {},
+        blocks: [
+          {
+            key: 'a',
+            text: 'test',
+          }
+        ]
+      }));
+      let selection = editorState.getSelection();
+      selection = selection.merge({
+        focusOffset: 4,
+      });
+      editorState = EditorState.acceptSelection(editorState, selection);
+      const wrapper = shallow((
+        <ModalWorkflowSource
+          editorState={editorState}
+          entityType={{}}
+          entity={{}}
+          onComplete={onComplete}
+          onClose={() => {}}
+        />
+      ));
+
+      wrapper.instance().onChosen({});
+
+      expect(onComplete).toHaveBeenCalled();
+      expect(RichUtils.toggleLink).toHaveBeenCalled();
+
+      RichUtils.toggleLink.mockRestore();
+    });
+
+    it('block', () => {
+      jest.spyOn(AtomicBlockUtils, 'insertAtomicBlock');
+
+      const onComplete = jest.fn();
+
+      let editorState = EditorState.createWithContent(convertFromRaw({
+        entityMap: {},
+        blocks: [
+          {
+            key: 'a',
+            text: 'test',
+          }
+        ]
+      }));
+      let selection = editorState.getSelection();
+      selection = selection.merge({
+        focusOffset: 4,
+      });
+      editorState = EditorState.acceptSelection(editorState, selection);
+      const wrapper = shallow((
+        <ModalWorkflowSource
+          editorState={editorState}
+          entityType={{
+            block: () => {},
+          }}
+          entity={{}}
+          onComplete={onComplete}
+          onClose={() => {}}
+        />
+      ));
+
+      wrapper.instance().onChosen({});
+
+      expect(onComplete).toHaveBeenCalled();
+      expect(AtomicBlockUtils.insertAtomicBlock).toHaveBeenCalled();
+
+      AtomicBlockUtils.insertAtomicBlock.mockRestore();
+    });
+
+    it('prefer_this_title_as_link_text', () => {
+      jest.spyOn(Modifier, 'replaceText');
+
+      const onComplete = jest.fn();
+
+      let editorState = EditorState.createWithContent(convertFromRaw({
+        entityMap: {},
+        blocks: [
+          {
+            key: 'a',
+            text: 'test',
+          }
+        ]
+      }));
+      let selection = editorState.getSelection();
+      selection = selection.merge({
+        focusOffset: 4,
+      });
+      editorState = EditorState.acceptSelection(editorState, selection);
+      const wrapper = shallow((
+        <ModalWorkflowSource
+          editorState={editorState}
+          entityType={{}}
+          onComplete={onComplete}
+          onClose={() => {}}
+        />
+      ));
+
+      wrapper.instance().onChosen({
+        url: 'example.com',
+        prefer_this_title_as_link_text: true,
+      });
+
+      expect(onComplete).toHaveBeenCalled();
+      expect(Modifier.replaceText).toHaveBeenCalled();
+
+      Modifier.replaceText.mockRestore();
+    });
+  });
+
+  it('#onClose', () => {
+    const onClose = jest.fn();
+    const wrapper = shallow((
+      <ModalWorkflowSource
+        editorState={{}}
+        entityType={{}}
+        entity={{}}
+        onComplete={() => {}}
+        onClose={onClose}
+      />
+    ));
+
+    wrapper.instance().onClose({
+      preventDefault: () => {},
+    });
+
+    expect(onClose).toHaveBeenCalled();
+  });
+});

+ 105 - 0
client/src/components/Draftail/sources/__snapshots__/ModalWorkflowSource.test.js.snap

@@ -0,0 +1,105 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ModalWorkflowSource #filterEntityData DOCUMENT 1`] = `
+Object {
+  "filename": "test.pdf",
+  "id": 1,
+  "url": "/documents/1/test.pdf",
+}
+`;
+
+exports[`ModalWorkflowSource #filterEntityData EMBED 1`] = `
+Object {
+  "authorName": "Test",
+  "embedType": "video",
+  "providerName": "YouTube",
+  "thumbnail": "https://i.ytimg.com/vi/pSlVtxLOYiM/hqdefault.jpg",
+  "title": "Test",
+  "url": "https://www.youtube.com/watch?v=pSlVtxLOYiM",
+}
+`;
+
+exports[`ModalWorkflowSource #filterEntityData IMAGE 1`] = `
+Object {
+  "alt": "Test",
+  "format": "right",
+  "id": 53,
+  "src": "/media/images/test.width-500.jpg",
+}
+`;
+
+exports[`ModalWorkflowSource #filterEntityData LINK external 1`] = `
+Object {
+  "url": "https://www.example.com/",
+}
+`;
+
+exports[`ModalWorkflowSource #filterEntityData LINK mail 1`] = `
+Object {
+  "url": "mailto:test@example.com",
+}
+`;
+
+exports[`ModalWorkflowSource #filterEntityData LINK page 1`] = `
+Object {
+  "id": 60,
+  "parentId": 1,
+  "url": "/",
+}
+`;
+
+exports[`ModalWorkflowSource #getChooserConfig LINK external 1`] = `
+Object {
+  "url": "/admin/choose-external-link/",
+  "urlParams": Object {
+    "allow_email_link": true,
+    "allow_external_link": true,
+    "can_choose_root": "false",
+    "link_text": "",
+    "link_url": "https://www.example.com/",
+    "page_type": "wagtailcore.page",
+  },
+}
+`;
+
+exports[`ModalWorkflowSource #getChooserConfig LINK mail 1`] = `
+Object {
+  "url": "/admin/choose-email-link/",
+  "urlParams": Object {
+    "allow_email_link": true,
+    "allow_external_link": true,
+    "can_choose_root": "false",
+    "link_text": "",
+    "link_url": "test@example.com",
+    "page_type": "wagtailcore.page",
+  },
+}
+`;
+
+exports[`ModalWorkflowSource #getChooserConfig LINK no entity 1`] = `
+Object {
+  "url": "/admin/choose-page/",
+  "urlParams": Object {
+    "allow_email_link": true,
+    "allow_external_link": true,
+    "can_choose_root": "false",
+    "link_text": "",
+    "page_type": "wagtailcore.page",
+  },
+}
+`;
+
+exports[`ModalWorkflowSource #getChooserConfig LINK page 1`] = `
+Object {
+  "url": "/admin/choose-page/0/",
+  "urlParams": Object {
+    "allow_email_link": true,
+    "allow_external_link": true,
+    "can_choose_root": "false",
+    "link_text": "",
+    "page_type": "wagtailcore.page",
+  },
+}
+`;
+
+exports[`ModalWorkflowSource works 1`] = `""`;

+ 3 - 3
client/src/components/Portal/Portal.test.js

@@ -4,7 +4,7 @@ import Portal from './Portal';
 
 const func = expect.any(Function);
 
-describe.skip('Portal', () => {
+describe('Portal', () => {
   beforeEach(() => {
     document.body.innerHTML = '';
   });
@@ -18,8 +18,8 @@ describe.skip('Portal', () => {
   });
 
   it('component lifecycle', () => {
-    jest.spyOn(document, 'removeEventListener');
-    jest.spyOn(window, 'removeEventListener');
+    document.removeEventListener = jest.fn();
+    window.removeEventListener = jest.fn();
 
     const wrapper = shallow(<Portal onClose={() => {}}>Test!</Portal>);
 

+ 3 - 3
client/src/components/Portal/__snapshots__/Portal.test.js.snap

@@ -1,7 +1,7 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
-exports[`Portal #children 1`] = `null`;
+exports[`Portal #children 1`] = `""`;
 
-exports[`Portal component lifecycle 1`] = `"<div><div data-reactroot=\\"\\">Test!</div></div>"`;
+exports[`Portal component lifecycle 1`] = `"<div><div>Test!</div></div>"`;
 
-exports[`Portal empty 1`] = `null`;
+exports[`Portal empty 1`] = `""`;

+ 17 - 0
client/tests/stubs.js

@@ -39,3 +39,20 @@ global.wagtailConfig = {
 };
 
 global.wagtailVersion = '1.6a1';
+
+global.chooserUrls = {
+  documentChooser: '/admin/documents/chooser/',
+  emailLinkChooser: '/admin/choose-email-link/',
+  embedsChooser: '/admin/embeds/chooser/',
+  externalLinkChooser: '/admin/choose-external-link/',
+  imageChooser: '/admin/images/chooser/',
+  pageChooser: '/admin/choose-page/',
+  snippetChooser: '/admin/snippets/choose/',
+};
+
+const jQueryObj = {
+  on: jest.fn(),
+  off: jest.fn(),
+};
+
+global.jQuery = () => jQueryObj;

+ 29 - 3
wagtail/admin/static_src/wagtailadmin/app/draftail.entry.js

@@ -1,4 +1,30 @@
-import draftail from '../../../../../client/src/components/Draftail/index';
+import {
+  initEditor,
+  registry,
+  ModalWorkflowSource,
+  Link,
+  Document,
+  ImageBlock,
+  EmbedBlock,
+} from '../../../../../client/src/components/Draftail/index';
 
-// Expose as a global variable, for integration with other scripts.
-window.draftail = draftail;
+/**
+ * Expose as a global, and register the built-in entities.
+ */
+
+window.draftail = registry;
+window.draftail.initEditor = initEditor;
+
+window.draftail.registerSources({
+  ModalWorkflowSource,
+});
+
+window.draftail.registerDecorators({
+  Link,
+  Document,
+});
+
+window.draftail.registerBlocks({
+  ImageBlock,
+  EmbedBlock,
+});

+ 8 - 0
wagtail/admin/static_src/wagtailadmin/app/draftail.entry.test.js

@@ -4,4 +4,12 @@ describe('draftail.entry', () => {
   it('exposes global', () => {
     expect(window.draftail).toBeDefined();
   });
+
+  it('has defaults registered', () => {
+    expect(window.draftail.getSource('ModalWorkflowSource')).toBeDefined();
+    expect(window.draftail.getDecorator('Link')).toBeDefined();
+    expect(window.draftail.getDecorator('Document')).toBeDefined();
+    expect(window.draftail.getBlock('ImageBlock')).toBeDefined();
+    expect(window.draftail.getBlock('EmbedBlock')).toBeDefined();
+  });
 });