Browse Source

Add edit button to draftail images and embeds blocks tooltip. Fix #2674 (#5885)

Maylon Pedroso 4 năm trước cách đây
mục cha
commit
93a8227a52

+ 4 - 2
client/src/components/Draftail/blocks/EmbedBlock.js

@@ -9,7 +9,7 @@ import MediaBlock from '../blocks/MediaBlock';
  * Editor block to display media and edit content.
  */
 const EmbedBlock = props => {
-  const { entity, onRemoveEntity } = props.blockProps;
+  const { entity, onEditEntity, onRemoveEntity } = props.blockProps;
   const { url, title, thumbnail } = entity.getData();
 
   return (
@@ -25,7 +25,9 @@ const EmbedBlock = props => {
           {title}
         </a>
       ) : null}
-
+      <button className="button Tooltip__button" type="button" onClick={onEditEntity}>
+        {STRINGS.EDIT}
+      </button>
       <button className="button button-secondary no Tooltip__button" onClick={onRemoveEntity}>
         {STRINGS.DELETE}
       </button>

+ 6 - 0
client/src/components/Draftail/blocks/EmbedBlock.test.js

@@ -8,7 +8,9 @@ describe('EmbedBlock', () => {
     expect(
       shallow(
         <EmbedBlock
+          block={{}}
           blockProps={{
+            editorState: {},
             entityType: {},
             entity: {
               getData: () => ({
@@ -17,6 +19,7 @@ describe('EmbedBlock', () => {
                 thumbnail: 'http://www.example.com/example.png',
               }),
             },
+            onChange: () => {},
           }}
         />
       )
@@ -27,11 +30,14 @@ describe('EmbedBlock', () => {
     expect(
       shallow(
         <EmbedBlock
+          block={{}}
           blockProps={{
+            editorState: {},
             entityType: {},
             entity: {
               getData: () => ({}),
             },
+            onChange: () => {},
           }}
         />
       )

+ 20 - 37
client/src/components/Draftail/blocks/ImageBlock.js

@@ -1,6 +1,5 @@
 import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import { DraftUtils } from 'draftail';
+import React from 'react';
 
 import { STRINGS } from '../../../config/wagtailConfig';
 
@@ -9,41 +8,25 @@ import MediaBlock from '../blocks/MediaBlock';
 /**
  * Editor block to preview and edit images.
  */
-class ImageBlock extends Component {
-  constructor(props) {
-    super(props);
-
-    this.changeAlt = this.changeAlt.bind(this);
-  }
-
-  changeAlt(e) {
-    const { block, blockProps } = this.props;
-    const { editorState, onChange } = blockProps;
-
-    const data = {
-      alt: e.target.value,
-    };
-
-    onChange(DraftUtils.updateBlockEntity(editorState, block, data));
-  }
-
-  render() {
-    const { blockProps } = this.props;
-    const { entity, onRemoveEntity } = blockProps;
-    const { src, alt } = entity.getData();
-    const altLabel = `${STRINGS.ALT_TEXT}: “${alt || ''}”`;
-
-    return (
-      <MediaBlock {...this.props} src={src} alt="">
-        <p className="ImageBlock__alt">{altLabel}</p>
-
-        <button className="button button-secondary no Tooltip__button" onClick={onRemoveEntity}>
-          {STRINGS.DELETE}
-        </button>
-      </MediaBlock>
-    );
-  }
-}
+const ImageBlock = props => {
+  const { blockProps } = props;
+  const { entity, onEditEntity, onRemoveEntity } = blockProps;
+  const { src, alt } = entity.getData();
+  const altLabel = `${STRINGS.ALT_TEXT}: “${alt || ''}”`;
+
+  return (
+    <MediaBlock {...props} src={src} alt="">
+      <p className="ImageBlock__alt">{altLabel}</p>
+
+      <button className="button Tooltip__button" type="button" onClick={onEditEntity}>
+        {STRINGS.EDIT}
+      </button>
+      <button className="button button-secondary no Tooltip__button" onClick={onRemoveEntity}>
+        {STRINGS.DELETE}
+      </button>
+    </MediaBlock>
+  );
+};
 
 ImageBlock.propTypes = {
   block: PropTypes.object.isRequired,

+ 0 - 46
client/src/components/Draftail/blocks/ImageBlock.test.js

@@ -1,8 +1,6 @@
 import React from 'react';
 import { shallow } from 'enzyme';
 
-import { DraftUtils } from 'draftail';
-
 import ImageBlock from '../blocks/ImageBlock';
 
 describe('ImageBlock', () => {
@@ -64,48 +62,4 @@ describe('ImageBlock', () => {
       )
     ).toMatchSnapshot();
   });
-
-  it('changeAlt', () => {
-    jest.spyOn(DraftUtils, 'updateBlockEntity');
-    DraftUtils.updateBlockEntity.mockImplementation(e => e);
-
-    const onChange = jest.fn();
-    const wrapper = shallow(
-      <ImageBlock
-        block={{}}
-        blockProps={{
-          editorState: {},
-          entityType: {},
-          entity: {
-            getData: () => ({
-              src: 'example.png',
-              alt: 'Test',
-            }),
-          },
-          onChange,
-        }}
-      />
-    );
-
-    // // 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(
-      expect.any(Object),
-      {},
-      expect.objectContaining({ alt: 'new alt' })
-    );
-
-    DraftUtils.updateBlockEntity.mockRestore();
-  });
 });

+ 26 - 2
client/src/components/Draftail/blocks/MediaBlock.js

@@ -4,6 +4,7 @@ import { Icon } from 'draftail';
 
 import Tooltip from '../Tooltip/Tooltip';
 import Portal from '../../Portal/Portal';
+import { SelectionState, EditorState } from 'draft-js';
 
 // Constraints the maximum size of the tooltip.
 const OPTIONS_MAX_WIDTH = 300;
@@ -21,12 +22,14 @@ class MediaBlock extends Component {
       showTooltipAt: null,
     };
 
+    this.onClick = this.onClick.bind(this);
+    this.selectCurrentBlock = this.selectCurrentBlock.bind(this);
     this.openTooltip = this.openTooltip.bind(this);
     this.closeTooltip = this.closeTooltip.bind(this);
     this.renderTooltip = this.renderTooltip.bind(this);
   }
 
-  openTooltip(e) {
+  onClick(e) {
     const trigger = e.target.closest('[data-draftail-trigger]');
 
     // Click is within the tooltip.
@@ -34,6 +37,24 @@ class MediaBlock extends Component {
       return;
     }
 
+    this.selectCurrentBlock();
+    this.openTooltip(trigger);
+  }
+
+  selectCurrentBlock() {
+    const { block, blockProps } = this.props;
+    const { editorState, onChange } = blockProps;
+    const selection = new SelectionState({
+      anchorKey: block.getKey(),
+      anchorOffset: 0,
+      focusKey: block.getKey(),
+      focusOffset: block.getLength(),
+      hasFocus: true,
+    });
+    onChange(EditorState.forceSelection(editorState, selection));
+  }
+
+  openTooltip(trigger) {
     const container = trigger.closest('[data-draftail-editor-wrapper]');
     const containerRect = container.getBoundingClientRect();
     const rect = trigger.getBoundingClientRect();
@@ -84,7 +105,7 @@ class MediaBlock extends Component {
         type="button"
         tabIndex={-1}
         className="MediaBlock"
-        onClick={this.openTooltip}
+        onClick={this.onClick}
         data-draftail-trigger
       >
         <span className="MediaBlock__icon-wrapper" aria-hidden>
@@ -102,7 +123,10 @@ class MediaBlock extends Component {
 MediaBlock.propTypes = {
   blockProps: PropTypes.shape({
     entityType: PropTypes.object.isRequired,
+    editorState: PropTypes.object.isRequired,
+    onChange: PropTypes.func.isRequired,
   }).isRequired,
+  block: PropTypes.object.isRequired,
   src: PropTypes.string,
   alt: PropTypes.string,
   children: PropTypes.node.isRequired,

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

@@ -2,6 +2,7 @@ import React from 'react';
 import { shallow, mount } from 'enzyme';
 
 import MediaBlock from '../blocks/MediaBlock';
+import { EditorState } from 'draft-js';
 
 describe('MediaBlock', () => {
   it('renders', () => {
@@ -10,7 +11,9 @@ describe('MediaBlock', () => {
         <MediaBlock
           src="example.png"
           alt=""
+          block={{}}
           blockProps={{
+            editorState: {},
             entityType: {
               icon: '#icon-test',
             },
@@ -19,6 +22,7 @@ describe('MediaBlock', () => {
                 src: 'example.png',
               }),
             },
+            onChange: () => {},
           }}
         >
           Test
@@ -33,13 +37,16 @@ describe('MediaBlock', () => {
         <MediaBlock
           src=""
           alt=""
+          block={{}}
           blockProps={{
+            editorState: {},
             entityType: {
               icon: '#icon-test',
             },
             entity: {
               getData: () => ({}),
             },
+            onChange: () => {},
           }}
         >
           Test
@@ -48,37 +55,61 @@ describe('MediaBlock', () => {
     ).toMatchSnapshot();
   });
 
-  describe('tooltip', () => {
+  describe('on click', () => {
     let target;
     let wrapper;
+    let blockProps;
 
     beforeEach(() => {
       target = document.createElement('div');
       target.setAttribute('data-draftail-trigger', true);
       document.body.appendChild(target);
       document.body.setAttribute('data-draftail-editor-wrapper', true);
-
+      blockProps = {
+        editorState: EditorState.createEmpty(),
+        entityType: {
+          icon: '#icon-test',
+        },
+        entity: {
+          getData: () => ({
+            src: 'example.png',
+          }),
+        },
+        onChange: () => {},
+      };
       wrapper = mount(
         <MediaBlock
           src="example.png"
           alt=""
-          blockProps={{
-            entityType: {
-              icon: '#icon-test',
-            },
-            entity: {
-              getData: () => ({
-                src: 'example.png',
-              }),
-            },
+          block={{
+            getKey: () => 'abcde',
+            getLength: () => 1,
           }}
+          blockProps={blockProps}
         >
           <div id="test">Test</div>
         </MediaBlock>
       );
     });
 
-    it('opens', () => {
+    it('selected', () => {
+      blockProps.onChange = (editorState) => {
+        const selecttion = editorState.getSelection();
+
+        expect(selecttion.getAnchorKey()).toEqual('abcde');
+        expect(selecttion.getAnchorOffset()).toEqual(0);
+        expect(selecttion.getFocusKey()).toEqual('abcde');
+        expect(selecttion.getFocusOffset()).toEqual(1);
+      };
+
+      jest.spyOn(blockProps, 'onChange');
+
+      wrapper.simulate('click', { target });
+
+      expect(blockProps.onChange).toHaveBeenCalled();
+    });
+
+    it('tooltip opens', () => {
       wrapper.simulate('click', { target });
 
       expect(
@@ -97,7 +128,7 @@ describe('MediaBlock', () => {
       expect(target.getBoundingClientRect).not.toHaveBeenCalled();
     });
 
-    it('large viewport', () => {
+    it('tooltip in large viewport', () => {
       target.getBoundingClientRect = () => ({
         top: 0,
         left: 0,
@@ -114,7 +145,7 @@ describe('MediaBlock', () => {
       ).toBe('Tooltip Tooltip--left');
     });
 
-    it('closes', () => {
+    it('tooltip closes', () => {
       jest.spyOn(target, 'getBoundingClientRect');
 
       expect(wrapper.state('showTooltipAt')).toBe(null);

+ 18 - 0
client/src/components/Draftail/blocks/__snapshots__/EmbedBlock.test.js.snap

@@ -3,16 +3,25 @@
 exports[`EmbedBlock no data 1`] = `
 <MediaBlock
   alt=""
+  block={Object {}}
   blockProps={
     Object {
+      "editorState": Object {},
       "entity": Object {
         "getData": [Function],
       },
       "entityType": Object {},
+      "onChange": [Function],
     }
   }
   src={null}
 >
+  <button
+    className="button Tooltip__button"
+    type="button"
+  >
+    Edit
+  </button>
   <button
     className="button button-secondary no Tooltip__button"
   >
@@ -24,12 +33,15 @@ exports[`EmbedBlock no data 1`] = `
 exports[`EmbedBlock renders 1`] = `
 <MediaBlock
   alt=""
+  block={Object {}}
   blockProps={
     Object {
+      "editorState": Object {},
       "entity": Object {
         "getData": [Function],
       },
       "entityType": Object {},
+      "onChange": [Function],
     }
   }
   src="http://www.example.com/example.png"
@@ -43,6 +55,12 @@ exports[`EmbedBlock renders 1`] = `
   >
     Test title
   </a>
+  <button
+    className="button Tooltip__button"
+    type="button"
+  >
+    Edit
+  </button>
   <button
     className="button button-secondary no Tooltip__button"
   >

+ 18 - 0
client/src/components/Draftail/blocks/__snapshots__/ImageBlock.test.js.snap

@@ -21,6 +21,12 @@ exports[`ImageBlock alt 1`] = `
   >
     Alt text: “Test”
   </p>
+  <button
+    className="button Tooltip__button"
+    type="button"
+  >
+    Edit
+  </button>
   <button
     className="button button-secondary no Tooltip__button"
   >
@@ -50,6 +56,12 @@ exports[`ImageBlock no data 1`] = `
   >
     Alt text: “”
   </p>
+  <button
+    className="button Tooltip__button"
+    type="button"
+  >
+    Edit
+  </button>
   <button
     className="button button-secondary no Tooltip__button"
   >
@@ -79,6 +91,12 @@ exports[`ImageBlock renders 1`] = `
   >
     Alt text: “”
   </p>
+  <button
+    className="button Tooltip__button"
+    type="button"
+  >
+    Edit
+  </button>
   <button
     className="button button-secondary no Tooltip__button"
   >

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

@@ -54,7 +54,7 @@ exports[`MediaBlock renders 1`] = `
 </button>
 `;
 
-exports[`MediaBlock tooltip opens 1`] = `
+exports[`MediaBlock on click tooltip opens 1`] = `
 <div>
   <div
     class="Tooltip Tooltip--top-left"

+ 37 - 16
client/src/components/Draftail/sources/ModalWorkflowSource.js

@@ -1,7 +1,7 @@
 import PropTypes from 'prop-types';
 import { Component } from 'react';
 import { AtomicBlockUtils, Modifier, RichUtils, EditorState } from 'draft-js';
-import { ENTITY_TYPE } from 'draftail';
+import { ENTITY_TYPE, DraftUtils } from 'draftail';
 
 import { STRINGS } from '../../../config/wagtailConfig';
 import { getSelectionText } from '../DraftUtils';
@@ -23,16 +23,31 @@ export const getChooserConfig = (entityType, entity, selectedText) => {
 
   switch (entityType.type) {
   case ENTITY_TYPE.IMAGE:
+    if (entity) {
+      const data = entity.getData();
+      url = `${global.chooserUrls.imageChooser}${data.id}/select_format/`;
+      urlParams = {
+        format: data.format,
+        alt_text: data.alt,
+      };
+    } else {
+      url = `${global.chooserUrls.imageChooser}?select_format=true`;
+      urlParams = {};
+    }
     return {
-      url: `${global.chooserUrls.imageChooser}?select_format=true`,
-      urlParams: {},
+      url,
+      urlParams,
       onload: global.IMAGE_CHOOSER_MODAL_ONLOAD_HANDLERS,
     };
 
   case EMBED:
+    urlParams = {};
+    if (entity) {
+      urlParams.url = entity.getData().url;
+    }
     return {
       url: global.chooserUrls.embedsChooser,
-      urlParams: {},
+      urlParams,
       onload: global.EMBED_CHOOSER_MODAL_ONLOAD_HANDLERS,
     };
 
@@ -180,33 +195,38 @@ class ModalWorkflowSource extends Component {
   }
 
   onChosen(data) {
-    const { editorState, entityType, onComplete } = this.props;
+    const { editorState, entity, entityKey, entityType, onComplete } = this.props;
     const content = editorState.getCurrentContent();
     const selection = editorState.getSelection();
-
     const entityData = filterEntityData(entityType, data);
     const mutability = MUTABILITY[entityType.type];
-    const contentWithEntity = content.createEntity(entityType.type, mutability, entityData);
-    const entityKey = contentWithEntity.getLastCreatedEntityKey();
 
     let nextState;
-
     if (entityType.block) {
-      // Only supports adding entities at the moment, not editing existing ones.
-      // See https://github.com/springload/draftail/blob/cdc8988fe2e3ac32374317f535a5338ab97e8637/examples/sources/ImageSource.js#L44-L62.
-      // See https://github.com/springload/draftail/blob/cdc8988fe2e3ac32374317f535a5338ab97e8637/examples/sources/EmbedSource.js#L64-L91
-      nextState = AtomicBlockUtils.insertAtomicBlock(editorState, entityKey, ' ');
+      if (entity && entityKey) {
+        // Replace the data for the currently selected block
+        const blockKey = selection.getAnchorKey();
+        const block = content.getBlockForKey(blockKey);
+        nextState = DraftUtils.updateBlockEntity(editorState, block, entityData);
+      } else {
+        // Add new entity if there is none selected
+        const contentWithEntity = content.createEntity(entityType.type, mutability, entityData);
+        const newEntityKey = contentWithEntity.getLastCreatedEntityKey();
+        nextState = AtomicBlockUtils.insertAtomicBlock(editorState, newEntityKey, ' ');
+      }
     } else {
+      const contentWithEntity = content.createEntity(entityType.type, mutability, entityData);
+      const newEntityKey = contentWithEntity.getLastCreatedEntityKey();
+
       // Replace text if the chooser demands it, or if there is no selected text in the first place.
       const shouldReplaceText = data.prefer_this_title_as_link_text || selection.isCollapsed();
-
       if (shouldReplaceText) {
         // If there is a title attribute, use it. Otherwise we inject the URL.
         const newText = data.title || data.url;
-        const newContent = Modifier.replaceText(content, selection, newText, null, entityKey);
+        const newContent = Modifier.replaceText(content, selection, newText, null, newEntityKey);
         nextState = EditorState.push(editorState, newContent, 'insert-characters');
       } else {
-        nextState = RichUtils.toggleLink(editorState, selection, entityKey);
+        nextState = RichUtils.toggleLink(editorState, selection, newEntityKey);
       }
     }
 
@@ -234,6 +254,7 @@ ModalWorkflowSource.propTypes = {
   editorState: PropTypes.object.isRequired,
   entityType: PropTypes.object.isRequired,
   entity: PropTypes.object,
+  entityKey: PropTypes.string,
   onComplete: PropTypes.func.isRequired,
   onClose: PropTypes.func.isRequired,
 };

+ 76 - 3
client/src/components/Draftail/sources/ModalWorkflowSource.test.js

@@ -3,6 +3,7 @@ import { shallow } from 'enzyme';
 
 import ModalWorkflowSource, { getChooserConfig, filterEntityData } from './ModalWorkflowSource';
 import * as DraftUtils from '../DraftUtils';
+import { DraftUtils as DraftailUtils } from 'draftail';
 import { EditorState, convertFromRaw, AtomicBlockUtils, RichUtils, Modifier } from 'draft-js';
 
 global.ModalWorkflow = () => {};
@@ -30,7 +31,7 @@ describe('ModalWorkflowSource', () => {
   });
 
   describe('#getChooserConfig', () => {
-    it('IMAGE', () => {
+    it('IMAGE without entity', () => {
       expect(getChooserConfig({ type: 'IMAGE' }, null, '')).toEqual({
         url: '/admin/images/chooser/?select_format=true',
         urlParams: {},
@@ -38,7 +39,19 @@ describe('ModalWorkflowSource', () => {
       });
     });
 
-    it('EMBED', () => {
+    it('IMAGE with entity', () => {
+      const entity = { getData: () => ({ id: 1, format: 'left', alt: 'alt' }) };
+      expect(getChooserConfig({ type: 'IMAGE' }, entity, '')).toEqual({
+        url: '/admin/images/chooser/1/select_format/',
+        urlParams: {
+          format: 'left',
+          alt_text: 'alt',
+        },
+        onload: global.IMAGE_CHOOSER_MODAL_ONLOAD_HANDLERS,
+      });
+    });
+
+    it('EMBED without entity', () => {
       expect(getChooserConfig({ type: 'EMBED' }, null, '')).toEqual({
         url: '/admin/embeds/chooser/',
         urlParams: {},
@@ -46,6 +59,15 @@ describe('ModalWorkflowSource', () => {
       });
     });
 
+    it('EMBED with entity', () => {
+      const entity = { getData: () => ({ url: 'http://example.org/content' }) };
+      expect(getChooserConfig({ type: 'EMBED' }, entity, '')).toEqual({
+        url: '/admin/embeds/chooser/',
+        urlParams: { url: 'http://example.org/content' },
+        onload: global.EMBED_CHOOSER_MODAL_ONLOAD_HANDLERS,
+      });
+    });
+
     it('DOCUMENT', () => {
       expect(getChooserConfig({ type: 'DOCUMENT' }, null, '')).toEqual({
         url: '/admin/documents/chooser/',
@@ -265,7 +287,7 @@ describe('ModalWorkflowSource', () => {
       RichUtils.toggleLink.mockRestore();
     });
 
-    it('block', () => {
+    it('block for new entity', () => {
       jest.spyOn(AtomicBlockUtils, 'insertAtomicBlock');
 
       const onComplete = jest.fn();
@@ -307,6 +329,57 @@ describe('ModalWorkflowSource', () => {
       AtomicBlockUtils.insertAtomicBlock.mockRestore();
     });
 
+    it('block for existing entity', () => {
+      jest.spyOn(DraftailUtils, 'updateBlockEntity');
+      const onComplete = jest.fn();
+      const close = jest.fn();
+
+      let editorState = EditorState.createWithContent(convertFromRaw({
+        blocks: [
+          {
+            key: 'a',
+            text: ' ',
+            type: 'atomic',
+            entityRanges: [{ offset: 0, length: 1, key: 'first' }],
+            data: {},
+          }
+        ],
+        entityMap: {
+          first: {
+            type: 'IMAGE',
+            mutability: 'IMMUTABLE',
+            data: {},
+          }
+        }
+      }));
+      let selection = editorState.getSelection();
+      selection = selection.merge({
+        anchorKey: 'a',
+      });
+      editorState = EditorState.acceptSelection(editorState, selection);
+      const wrapper = shallow((
+        <ModalWorkflowSource
+          editorState={editorState}
+          entityType={{
+            block: () => {},
+          }}
+          entity={{}}
+          entityKey={'first'}
+          onComplete={onComplete}
+          onClose={() => {}}
+        />
+      ));
+
+      wrapper.instance().workflow = { close };
+      wrapper.instance().onChosen({});
+
+      expect(onComplete).toHaveBeenCalled();
+      expect(DraftailUtils.updateBlockEntity).toHaveBeenCalled();
+      expect(close).toHaveBeenCalled();
+
+      DraftailUtils.updateBlockEntity.mockRestore();
+    });
+
     it('prefer_this_title_as_link_text', () => {
       jest.spyOn(Modifier, 'replaceText');
 

+ 1 - 0
client/tests/stubs.js

@@ -21,6 +21,7 @@ global.wagtailConfig = {
   },
   STRINGS: {
     DELETE: 'Delete',
+    EDIT: 'Edit',
     PAGE: 'Page',
     PAGES: 'Pages',
     LOADING: 'Loading…',

+ 1 - 0
wagtail/admin/localization.py

@@ -50,6 +50,7 @@ WAGTAILADMIN_PROVIDED_LANGUAGES = [
 def get_js_translation_strings():
     return {
         'DELETE': _('Delete'),
+        'EDIT': _('Edit'),
         'PAGE': _('Page'),
         'PAGES': _('Pages'),
         'LOADING': _('Loading…'),