diff --git a/conf/storybook/.babelrc b/conf/storybook/.babelrc new file mode 100644 index 0000000..3c45d73 --- /dev/null +++ b/conf/storybook/.babelrc @@ -0,0 +1,7 @@ +{ + "presets": [ + "es2015", + "react", + "stage-0" + ] +} diff --git a/package.json b/package.json index c4f1900..b2e0338 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ }, "homepage": "https://github.com/StoryShop/app#readme", "devDependencies": { - "@kadira/storybook": "^2.14.0", + "@kadira/storybook": "^2.5.2", "autoprefixer": "^6.3.3", "babel-loader": "^6.2.2", "babel-polyfill": "^6.5.0", diff --git a/src/components/editor/index.js b/src/components/editor/index.js index e465bfc..0d074cb 100644 --- a/src/components/editor/index.js +++ b/src/components/editor/index.js @@ -32,21 +32,10 @@ const EditorComponent = (React, ...behaviours) => reactStamp(React).compose({ }, init() { - const content = convertFromRaw(this.props.content); this.state = { - editor: EditorState.createWithContent(content), + editor: EditorState.createWithContent( convertFromRaw( this.props.content ) ), readOnly: true, }; - - this.onClick = this.onClick.bind(this); - this.onChange = this.onChange.bind(this); - this.onFocus = this.onFocus.bind(this); - this.onBlur = this.onBlur.bind(this); - - this.handleKeyCommand = this.handleKeyCommand.bind(this); - this.handleReturn = this.handleReturn.bind(this); - - this.save = this.save.bind(this); }, componentWillUnmount() { @@ -86,7 +75,7 @@ const EditorComponent = (React, ...behaviours) => reactStamp(React).compose({ if (this.props.onChange) { this.deleteTimeout(); - this._timeout = setTimeout(this.save, 3000); + this._timeout = setTimeout(::this.save, 3000); } }, @@ -103,119 +92,20 @@ const EditorComponent = (React, ...behaviours) => reactStamp(React).compose({ } }, - handleKeyCommand(command) { - const { editor } = this.state; - const newState = RichUtils.handleKeyCommand(editor, command); - if (newState) { - this.onChange(newState); - return true; - } - return false; - }, - - handleReturn(e) { - const { editor } = this.state; - const selection = editor.getSelection(); - const content = editor.getCurrentContent(); - const block = content.getBlockForKey(selection.getStartKey()); - - // We only care if there is no current selection (e.g. selection is a caret). - if (!selection.isCollapsed()) { - console.log("not collapsed") - return; - } - - // We only care if we're at the end of the line. - // TODO: implement splitting current block at selection - if (block.getLength() !== selection.getStartOffset()) { - console.log("not at end of line") - return; - } - - const previousBlock = content.getBlockBefore(block.getKey()); - if (block.getText().length === 0) { - if (!previousBlock || previousBlock.getText().length === 0) { - // no empty lines between paragraphs - return true; - } else { - // insert header block - this._insertHeader(); - return true; - } - } else if (block.getType() === 'unstyled') { - // current line is non-empty and is unstyled already, so let the browser do its thing and - // insert another one. - return false; - } else { - // non-empty and not unstyled, so let's insert a new paragraph and move the cursor there. - this._insertParagraph(); - return true; - } - }, - - _insertParagraph () { - return this._insertBlock({ - key: genKey(), - type: 'unstyled', - text: '', - characterList: List() - }); - }, - - _insertBlock (blockData) { - const { editor } = this.state; - const selection = editor.getSelection(); - const content = editor.getCurrentContent(); - const block = content.getBlockForKey(selection.getStartKey()); - - // Insert new unstyled block - const newBlock = new ContentBlock(blockData); - - const blockArr = []; - content.getBlockMap().forEach((oldBlock, key) => { - blockArr.push(oldBlock); - - if (key === block.getKey()) { - blockArr.push(newBlock); - } - }); - - const newBlockMap = BlockMapBuilder.createFromArray(blockArr); - - const newContent = content.merge({ - blockMap: newBlockMap, - selectionBefore: selection, - selectionAfter: selection.merge({ - anchorKey: newBlock.getKey(), - anchorOffset: 0, - focusKey: newBlock.getKey(), - focusOffset: 0, - isBackward: false, - }), - }); - - const newState = EditorState.push(editor, newContent, 'insert-fragment'); - - this.setState({ editor: newState }); - }, - render() { + const { plugins = [] } = this.props; const isReadOnly = this.props.readOnly ? true : this.state.readOnly; - const plugins = (this.props.plugins || []).concat([stripPastePlugin]); + return ( -
- {isReadOnly ? 'Reading' : 'Writing'} +
); diff --git a/src/components/editor/paste-plugin/index.js b/src/components/editor/paste-plugin/index.js index 82675ea..357a471 100644 --- a/src/components/editor/paste-plugin/index.js +++ b/src/components/editor/paste-plugin/index.js @@ -1,54 +1,44 @@ import { EditorState, - ContentState, - ContentBlock, convertFromHTML, - convertToRaw, - convertFromRaw, - RichUtils, Modifier, BlockMapBuilder, - genKey, - CharacterMetadata, } from 'draft-js'; -function insertFragment(editorState, fragment, entityMap) { - var newContent = Modifier.replaceWithFragment( - editorState.getCurrentContent(), - editorState.getSelection(), - fragment - ); - - return EditorState.push( - editorState, - newContent, - 'insert-fragment' - ); -} - -const stripPastePlugin = (convert = convertFromHTML) => ({ +const stripPastePlugin = ( convert = convertFromHTML ) => ({ handlePastedText(text, html, { getEditorState, setEditorState }) { - if (html) { - const htmlFrag = convert(html); - if (htmlFrag) { - const ALLOWED_STYLE = ['ITALIC', 'BOLD']; - const contentBlocks = htmlFrag.map(block => { - const characterList = block.getCharacterList().map(list => { - const styles = list.getStyle().filter(s => { - return ALLOWED_STYLE.indexOf(s) !== -1; - }); - return list.set('style', styles); - }); - return block.set('type', 'unstyled').set('characterList', characterList); + if ( ! html ) { + return; + } + + const htmlFrag = convert( html ); + if ( ! htmlFrag ) { + return; + } + + const ALLOWED_STYLE = ['ITALIC', 'BOLD']; + const contentBlocks = htmlFrag.map(block => { + const characterList = block.getCharacterList().map(list => { + const styles = list.getStyle().filter(s => { + return ALLOWED_STYLE.indexOf(s) !== -1; }); + return list.set('style', styles); + }); + return block.set('type', 'unstyled').set('characterList', characterList); + }); - const htmlMap = BlockMapBuilder.createFromArray(contentBlocks); + const htmlMap = BlockMapBuilder.createFromArray(contentBlocks); - const newState = insertFragment(getEditorState(), htmlMap); - setEditorState(newState); - return true; - } - } + const editorState = getEditorState(); + const newContent = Modifier.replaceWithFragment( + editorState.getCurrentContent(), + editorState.getSelection(), + htmlMap + ); + + setEditorState( EditorState.push( editorState, newContent, 'insert-fragment' ) ); + + return true; }, }); diff --git a/src/components/editor/paste-plugin/spec.js b/src/components/editor/paste-plugin/spec.js index a6443e2..06703cf 100644 --- a/src/components/editor/paste-plugin/spec.js +++ b/src/components/editor/paste-plugin/spec.js @@ -1,5 +1,5 @@ import test from 'tape'; -import spy from 'utils/spy'; +import spy, { createSpy } from '../../../utils/spy'; import { EditorState, @@ -14,59 +14,93 @@ import { is, fromJS, List, Repeat, Record } from 'immutable'; import stripPastePluginFactory from './'; -const genBlock = (text, { key, style, type = 'unstyled' } = {}) => { - let newStyle = CharacterMetadata.EMPTY; - if (style) { - newStyle = CharacterMetadata.applyStyle(newStyle, style); - } +test( 'StripPastePlugin', t => { + let plugin, actual, expected, result, convertSpy; + + const html = '

does not matter

'; - return new ContentBlock({ - key, - type, - text, - characterList: List(Repeat(newStyle, text.length)) - }); -}; -const genContent = (text) => { - const contentState = ContentState.createFromBlockArray([ - genBlock(text, { key: 'abc' }), + const initalContentState = ContentState.createFromBlockArray([ + new ContentBlock({ + key: 'initial', + type: 'unstyled', + text: 'Initial', + characterList: List( Repeat( CharacterMetadata.EMPTY, 7 ) ), + }), ]); - return convertToRaw(contentState); -}; + const initialEditorState = EditorState.createWithContent( initalContentState ); -const stateFromRaw = (raw) => { - return EditorState.createWithContent(convertFromRaw(raw)); -} + const mocks = { + setEditorState: () => {}, + getEditorState: () => initialEditorState, + }; + const setEditorStateSpy = spy( mocks, 'setEditorState' ); + const getEditorStateSpy = spy( mocks, 'getEditorState' ); -test('StripPastePlugin strips all markup but italics and bolds', t => { - t.plan(1); + convertSpy = createSpy( () => [ + new ContentBlock({ + key: 'bold', + type: 'unstyled', + text: 'bold', + characterList: List( Repeat( CharacterMetadata.applyStyle( CharacterMetadata.EMPTY, 'BOLD' ), 4 ) ), + }), + new ContentBlock({ + key: 'header', + type: 'header-two', + text: 'header', + characterList: List( Repeat( CharacterMetadata.EMPTY, 6 ) ), + }), + ]); - const convert = () => { - return ([ - genBlock('boldy', { style: 'BOLD', key: 'b' }), - genBlock('yo', { type: 'header-two', key: 'h' }), - ]); - }; + plugin = stripPastePluginFactory( convertSpy ); - const stripPastePlugin = stripPastePluginFactory(convert); + /** + * Ignore Pasted Text + */ + { + result = plugin.handlePastedText( null, null, mocks ); + t.notOk( result, 'should return false when no html is provided' ); + } - const initialContent = stateFromRaw(genContent('Starter')); - const resultingContent = stateFromRaw(convertToRaw(ContentState.createFromBlockArray([ - genBlock('boldy', { style: 'BOLD', key: 'b' }), - genBlock('yoStarter', { key: 'h' }), - ]))); + /** + * Process Pasted HTML + */ + { + result = plugin.handlePastedText( null, html, mocks ); + t.ok( result, 'should return true' ); + t.equal( convertSpy.calls.length, 1, 'should call convert' ); + t.equal( convertSpy.calls[ 0 ].args[ 0 ], html, 'should call convert with the html' ); + t.equal( setEditorStateSpy.calls.length, 1, 'should call setEditorState' ); + } - const mocks = { setState: () => {} }; - const setEditorState = spy(mocks, 'setState'); - const getEditorState = () => initialContent; + /** + * Strip Pasted HTML + */ + { + let blocks = setEditorStateSpy.calls[ 0 ].args[ 0 ].getCurrentContent().getBlockMap(); - stripPastePlugin.handlePastedText(null, 'test', { getEditorState, setEditorState: mocks.setState }); + actual = blocks.count(); + expected = 2; + t.equal( actual, expected, 'should result in two blocks' ); - const blocksData = (state) => { - return JSON.stringify(convertToRaw(state).blocks.map(({ key, ...rest }) => rest)); - }; + let firstBlock = blocks.first(); + let secondBlock = blocks.last(); + + actual = firstBlock.get( 'text' ); + expected = 'bold'; + t.equals( actual, expected, 'first block should be first block of pasted text' ); + + actual = firstBlock.getInlineStyleAt( 0 ).toJS(); + expected = [ 'BOLD' ]; + t.deepEquals( actual, expected, 'pasted bold text should remain bold' ); + + actual = secondBlock.get( 'text' ); + expected = 'headerInitial'; + t.equals( actual, expected, 'second block should combine pasted with intial content' ); + + actual = secondBlock.get( 'type' ); + expected = 'unstyled'; + t.equals( actual, expected, 'second block should be unstyled' ); + } - const finalContent = blocksData(setEditorState.calls[0].args[0].getCurrentContent()); - const result = blocksData(resultingContent.getCurrentContent()); - t.equals(finalContent, result, 'Sets processed state'); + t.end(); }); diff --git a/src/components/editor/spec.js b/src/components/editor/spec.js index b78b1ad..b913dd1 100644 --- a/src/components/editor/spec.js +++ b/src/components/editor/spec.js @@ -1,7 +1,7 @@ import React from 'react'; import test from 'tape'; import { shallow, mount } from 'enzyme'; -import spy from 'utils/spy'; +import spy, { createSpy } from '../../utils/spy'; import { EditorState, @@ -12,97 +12,77 @@ import { convertFromRaw, genKey, } from 'draft-js'; -import { is, fromJS, List, Repeat } from 'immutable'; import EditorFactory from './'; import UpstreamEditor from 'draft-js-plugins-editor'; -const genContent = (text) => { - const contentState = ContentState.createFromBlockArray([ - new ContentBlock({ - key: 'abc', - type: 'unstyled', - text, - characterList: List(Repeat(CharacterMetadata.EMPTY, text.length)) - }), - ]); - return convertToRaw(contentState); -}; - -const stateFromRaw = (raw) => { - return EditorState.createWithContent(convertFromRaw(raw)); -} - -const getState = (el) => { - return el.find('span').text(); -}; - -test('Editor status', t => { - const Editor = EditorFactory(React); - t.plan(3); - - const content = genContent('Write here'); - const instance = shallow(); - - t.equals(getState(instance), 'Reading', 'Initial state is Reading'); - t.equals(instance.find(UpstreamEditor).length, 1, 'Draft.js Editor is render'); - - instance.simulate('click'); - - t.equals(getState(instance), 'Writing', 'Clicking changes it to Writing'); +const Editor = EditorFactory(React); + +test('Editor', t => { + let instance, actual, expected, editor; + + const content = { + entityMap: {}, + blocks: [{ text: '' }], + }; + + instance = shallow( ); + + /** + * Default state + */ + { + let editor = instance.find( UpstreamEditor ).at( 0 ); + t.ok( editor, 'should render a Draft Plugins Editor' ); + t.ok( editor.props().readOnly, 'should start in readOnly mode' ); + } + + /** + * Writable on click + */ + { + instance.simulate( 'click' ); + + let editor = instance.find( UpstreamEditor ).at( 0 ); + t.notOk( editor.props().readOnly, 'should move to writable mode on click' ); + } + + /** + * Read only + */ + { + instance = shallow( ); + instance.simulate( 'click' ); + + let editor = instance.find( UpstreamEditor ).at( 0 ); + t.ok( editor.props().readOnly, 'should not move to writeable on click if read only' ); + } + + /** + * Lifecycle methods + */ + { + let onEditStartSpy = createSpy(); + let onEditEndSpy = createSpy(); + + instance = shallow( + + ); + + t.equals( onEditStartSpy.calls.length, 0, 'onEditStart is not called initially'); + t.equals( onEditEndSpy.calls.length, 0, 'onEditEnd is not called initially'); + + instance.instance().onFocus(); + t.equals(onEditStartSpy.calls.length, 1, 'onEditStart is called after focusing in'); + + instance.instance().onBlur(); + t.equals(onEditEndSpy.calls.length, 1, 'onEditEnd is called after blurring out'); + } + + t.end(); }); -test('Editor read-only status', t => { - const Editor = EditorFactory(React); - t.plan(3); - - const content = genContent('Write here'); - const instance = shallow(); - - t.equals(getState(instance), 'Reading', 'Starts out as Reading'); - t.equals(instance.find(UpstreamEditor).length, 1); - - instance.simulate('click'); - - t.equals(getState(instance), 'Reading', 'Stays Reading even after the click'); -}); - -test('Editor is not managed', t => { - const Editor = EditorFactory(React); - t.plan(2); - - const content = genContent('This'); - const instance = shallow(); - - const expContent = convertFromRaw(content); - let currContent = instance.find(UpstreamEditor).props().editorState.getCurrentContent(); - - t.equals(is(currContent, expContent), true, 'Initial content is taken from props'); - - instance.setProps({ content: genContent('New') }); - - currContent = instance.find(UpstreamEditor).props().editorState.getCurrentContent(); - t.equals(is(currContent, expContent), true, 'Updating content prop does not change current content'); -}); - -test('Editor lifecycle', t => { - const Editor = EditorFactory(React); - t.plan(4); - - const content = genContent('Write here'); - const mocks = { onStart: () => {}, onEnd: () => {} }; - const onEditStart = spy(mocks, 'onStart'); - const onEditEnd = spy(mocks, 'onEnd'); - const instance = shallow(); - - t.equals(onEditStart.calls.length, 0, 'onEditStart is not called initially'); - t.equals(onEditEnd.calls.length, 0, 'onEditEnd is not called initially'); - - instance.instance().onFocus(); - - t.equals(onEditStart.calls.length, 1, 'onEditStart is called after focusing in'); - - instance.instance().onBlur(); - - t.equals(onEditEnd.calls.length, 1, 'onEditEnd is called after blurring out'); -}); diff --git a/src/utils/spy.js b/src/utils/spy.js index 6f7fc1a..1294e81 100644 --- a/src/utils/spy.js +++ b/src/utils/spy.js @@ -13,9 +13,28 @@ export default ( target, method ) => { args: args, }); - oldMethod.apply( target, args ); + return oldMethod.apply( target, args ); }; return spy; }; +export const createSpy = oldMethod => { + let spy; + + spy = function ( ...args ) { + spy.calls.push({ + args: args, + }); + + if ( oldMethod ) { + return oldMethod( ...args ); + } + }; + + spy.calls = []; + spy.reset = () => spy.calls = []; + + return spy; +}; +