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

pull/6256/head
Maylon Pedroso 2020-07-23 07:55:38 -05:00 zatwierdzone przez GitHub
rodzic f9d20d1fa6
commit 93a8227a52
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 4AEE18F83AFDEB23
13 zmienionych plików z 251 dodań i 119 usunięć

Wyświetl plik

@ -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>

Wyświetl plik

@ -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: () => {},
}}
/>
)

Wyświetl plik

@ -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);
const ImageBlock = props => {
const { blockProps } = props;
const { entity, onEditEntity, onRemoveEntity } = blockProps;
const { src, alt } = entity.getData();
const altLabel = `${STRINGS.ALT_TEXT}: “${alt || ''}`;
this.changeAlt = this.changeAlt.bind(this);
}
return (
<MediaBlock {...props} src={src} alt="">
<p className="ImageBlock__alt">{altLabel}</p>
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>
);
}
}
<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,

Wyświetl plik

@ -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();
});
});

Wyświetl plik

@ -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,

Wyświetl plik

@ -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);

Wyświetl plik

@ -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"
>

Wyświetl plik

@ -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"
>

Wyświetl plik

@ -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"

Wyświetl plik

@ -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,
};

Wyświetl plik

@ -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');

Wyświetl plik

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

Wyświetl plik

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