diff --git a/app/components/Editor/__tests__/Editor-test.js b/app/components/Editor/__tests__/Editor-test.js index ddcaed7d..a2cf4a9f 100644 --- a/app/components/Editor/__tests__/Editor-test.js +++ b/app/components/Editor/__tests__/Editor-test.js @@ -22,6 +22,7 @@ describe('', () => { onUpdateContent={() => {}} template={''} forceUpdate={false} + onClickCheckbox={() => {}} /> ); expect(wrapper.find(Markdown)).to.have.length(1); @@ -35,30 +36,12 @@ describe('', () => { onUpdateContent={() => {}} template={''} forceUpdate={false} + onClickCheckbox={() => {}} /> ); expect(wrapper.find(Preview)).to.have.length(1); }); - it('calls onUpdateContent() when text is entered in Markdown component', () => { - const spy = sinon.spy(); - - const wrapper = shallow( - - ); - const content = 'Hello, World'; - - wrapper.find('Markdown').simulate('change', content); - - expect(spy.calledOnce).to.be.true; - }); - it('renders a Loader component', () => { const wrapper = shallow( ', () => { onUpdateContent={() => {}} template={''} forceUpdate={false} + onClickCheckbox={() => {}} /> ); @@ -81,6 +65,7 @@ describe('', () => { onUpdateContent={() => {}} template={''} forceUpdate={false} + onClickCheckbox={() => {}} /> ); @@ -95,6 +80,7 @@ describe('', () => { onUpdateContent={() => {}} template={''} forceUpdate={false} + onClickCheckbox={() => {}} /> ); @@ -110,6 +96,7 @@ describe('', () => { onUpdateContent={spy} template={''} forceUpdate={false} + onClickCheckbox={() => {}} /> ); @@ -126,6 +113,7 @@ describe('', () => { onUpdateContent={() => {}} template={''} forceUpdate={false} + onClickCheckbox={() => {}} /> ); const verticalHandlerWrapper = wrapper.find('VerticalHandler').shallow(); @@ -146,6 +134,7 @@ describe('', () => { onUpdateContent={() => {}} template={''} forceUpdate={false} + onClickCheckbox={() => {}} /> ); const verticalHandlerWrapper = wrapper.find('VerticalHandler').shallow(); @@ -172,6 +161,7 @@ describe('', () => { onUpdateContent={() => {}} template={''} forceUpdate={false} + onClickCheckbox={() => {}} /> ); const verticalHandlerWrapper = wrapper.find('VerticalHandler').shallow(); @@ -192,6 +182,7 @@ describe('', () => { onUpdateContent={() => {}} template={''} forceUpdate={false} + onClickCheckbox={() => {}} /> ); const verticalHandlerWrapper = wrapper.find('VerticalHandler').shallow(); diff --git a/app/components/Editor/index.js b/app/components/Editor/index.js index bf4c35c6..cd927182 100644 --- a/app/components/Editor/index.js +++ b/app/components/Editor/index.js @@ -1,6 +1,9 @@ import { connect } from 'react-redux'; import Editor from './presenter'; -import { updateContent } from '../../modules/documents'; +import { + updateContent, + toggleTaskListItem, +} from '../../modules/documents'; const mapStateToProps = (state) => { @@ -18,6 +21,13 @@ const mapDispatchToProps = (dispatch) => ({ onUpdateContent: (content) => { dispatch(updateContent(content)); }, + onClickCheckbox: (index) => { + const idx = parseInt(index, 10); + + if (!isNaN(idx)) { + dispatch(toggleTaskListItem(idx)); + } + }, }); export default connect(mapStateToProps, mapDispatchToProps)(Editor); diff --git a/app/components/Editor/presenter.jsx b/app/components/Editor/presenter.jsx index 5965e86f..ee22eb64 100644 --- a/app/components/Editor/presenter.jsx +++ b/app/components/Editor/presenter.jsx @@ -75,6 +75,7 @@ export default class Editor extends Component { content={this.props.content} position={this.state.position} template={this.props.template} + onClickCheckbox={this.props.onClickCheckbox} /> ); @@ -86,5 +87,6 @@ Editor.propTypes = { content: PropTypes.string.isRequired, template: PropTypes.string.isRequired, onUpdateContent: PropTypes.func.isRequired, + onClickCheckbox: PropTypes.func.isRequired, forceUpdate: PropTypes.bool.isRequired, }; diff --git a/app/components/Preview/__tests__/Preview-test.js b/app/components/Preview/__tests__/Preview-test.js index 6656f4ac..36ef2778 100644 --- a/app/components/Preview/__tests__/Preview-test.js +++ b/app/components/Preview/__tests__/Preview-test.js @@ -4,7 +4,6 @@ import { expect } from 'chai'; import emojione from 'emojione'; import hljs from 'highlight.js'; import mdit from 'markdown-it'; -import katex from 'katex'; // plugins loaded in the PreviewLoader, which we cannot use in the test suite // since it is tied to webpack's require feature... @@ -18,6 +17,7 @@ import mditAbbr from 'markdown-it-abbr'; import mditKatex from 'markdown-it-katex'; import mditContainer from 'markdown-it-container'; import mditClassy from 'markdown-it-classy'; +import mditTaskLists from 'markdown-it-task-lists'; // see: https://github.com/mochajs/mocha/issues/1847 const { before, describe, it, Promise } = global; @@ -46,6 +46,7 @@ describe('', () => { mditClassy, ], markdownItContainer: mditContainer, + markdownItTaskLists: mditTaskLists, hljs: hljs, emojione: emojione }); @@ -54,14 +55,25 @@ describe('', () => { it('renders a block with preview css class', () => { const wrapper = shallow( - + {}} + /> ); expect(wrapper.find('.preview')).to.have.length(1); }); it('renders a loading message', () => { - const wrapper = render(); + const wrapper = render( + {}} + />); expect(wrapper.text()).to.contain('Loading all the rendering stuff...'); }); @@ -73,6 +85,7 @@ describe('', () => { position={0} previewLoader={previewLoader} template={''} + onClickCheckbox={() => {}} /> ); @@ -90,6 +103,7 @@ describe('', () => { position={0} previewLoader={previewLoader} template={''} + onClickCheckbox={() => {}} /> ); @@ -107,6 +121,7 @@ describe('', () => { position={0} previewLoader={previewLoader} template={''} + onClickCheckbox={() => {}} /> ); @@ -130,6 +145,7 @@ describe('', () => { position={0} previewLoader={previewLoader} template={''} + onClickCheckbox={() => {}} /> ); @@ -149,6 +165,7 @@ describe('', () => { position={0} previewLoader={previewLoader} template={''} + onClickCheckbox={() => {}} /> ); @@ -177,6 +194,7 @@ describe('', () => { position={0} previewLoader={previewLoader} template={''} + onClickCheckbox={() => {}} /> ); @@ -211,6 +229,7 @@ describe('', () => { position={0} previewLoader={previewLoader} template={''} + onClickCheckbox={() => {}} /> ); @@ -237,6 +256,7 @@ describe('', () => { position={0} previewLoader={previewLoader} template={''} + onClickCheckbox={() => {}} /> ); @@ -262,6 +282,7 @@ describe('', () => { position={0} previewLoader={previewLoader} template={''} + onClickCheckbox={() => {}} /> ); @@ -279,6 +300,7 @@ describe('', () => { position={0} previewLoader={previewLoader} template={''} + onClickCheckbox={() => {}} /> ); @@ -299,6 +321,7 @@ describe('', () => { template={'letter'} position={0} previewLoader={previewLoader} + onClickCheckbox={() => {}} /> ); @@ -328,6 +351,7 @@ describe('', () => { template={''} position={0} previewLoader={previewLoader} + onClickCheckbox={() => {}} /> ); @@ -358,6 +382,7 @@ describe('', () => { position={0} previewLoader={previewLoader} template={''} + onClickCheckbox={() => {}} /> ); @@ -376,6 +401,7 @@ describe('', () => { position={0} previewLoader={previewLoader} template={''} + onClickCheckbox={() => {}} /> ); @@ -394,6 +420,7 @@ describe('', () => { position={0} previewLoader={previewLoader} template={''} + onClickCheckbox={() => {}} /> ); @@ -411,6 +438,8 @@ describe('', () => { position={0} previewLoader={previewLoader} template={''} + onClickCheckbox={() => {}} + onClickCheckbox={() => {}} /> ); @@ -428,6 +457,7 @@ describe('', () => { position={0} previewLoader={previewLoader} template={''} + onClickCheckbox={() => {}} /> ); @@ -445,6 +475,7 @@ describe('', () => { position={0} previewLoader={previewLoader} template={''} + onClickCheckbox={() => {}} /> ); @@ -462,6 +493,7 @@ describe('', () => { position={0} previewLoader={previewLoader} template={''} + onClickCheckbox={() => {}} /> ); @@ -479,6 +511,7 @@ describe('', () => { position={0} previewLoader={previewLoader} template={''} + onClickCheckbox={() => {}} /> ); @@ -496,6 +529,7 @@ describe('', () => { position={0} previewLoader={previewLoader} template={''} + onClickCheckbox={() => {}} /> ); @@ -515,6 +549,7 @@ describe('', () => { position={0} previewLoader={previewLoader} template={''} + onClickCheckbox={() => {}} /> ); @@ -532,6 +567,7 @@ describe('', () => { position={0} previewLoader={previewLoader} template={''} + onClickCheckbox={() => {}} /> ); @@ -555,6 +591,7 @@ describe('', () => { position={0} previewLoader={previewLoader} template={''} + onClickCheckbox={() => {}} /> ); @@ -576,6 +613,7 @@ describe('', () => { position={0} previewLoader={previewLoader} template={''} + onClickCheckbox={() => {}} /> ); @@ -593,6 +631,7 @@ describe('', () => { position={0} previewLoader={previewLoader} template={''} + onClickCheckbox={() => {}} /> ); @@ -610,6 +649,7 @@ describe('', () => { position={0} previewLoader={previewLoader} template={''} + onClickCheckbox={() => {}} /> ); @@ -634,6 +674,7 @@ Annonce | Où | WM | Taille | Nb pièces | Etage | Balcon? | Cave/Gge/Parking | position={0} previewLoader={previewLoader} template={''} + onClickCheckbox={() => {}} /> ); @@ -644,4 +685,40 @@ Annonce | Où | WM | Taille | Nb pièces | Etage | Balcon? | Cave/Gge/Parking | done(); }); }); + + it('supports task lists', (done) => { + const wrapper = mount( + {}} + /> + ); + + setTimeout(() => { + expect(wrapper.html()).to.contain('
  • '); + + done(); + }); + }); + + it('should add a data attribute to each task list item', (done) => { + const wrapper = mount( + {}} + /> + ); + + setTimeout(() => { + expect(wrapper.html()).to.contain('data-task-list-item-index="0"'); + + done(); + }); + }); }); diff --git a/app/components/Preview/presenter.jsx b/app/components/Preview/presenter.jsx index 83062e2c..3975a100 100644 --- a/app/components/Preview/presenter.jsx +++ b/app/components/Preview/presenter.jsx @@ -16,6 +16,7 @@ class Preview extends Component { this.requestAnimationId = false; this.setRenderedEl = this.setRenderedEl.bind(this); + this.onClickCheckbox = this.onClickCheckbox.bind(this); } componentWillMount() { @@ -57,6 +58,10 @@ class Preview extends Component { }, }); + this.markdownIt.use(deps.markdownItTaskLists, { + enabled: true, + }); + this.emojione = deps.emojione; this.emojione.ascii = true; this.emojione.sprites = true; @@ -65,6 +70,10 @@ class Preview extends Component { }); } + componentDidMount() { + this.$rendered.addEventListener('click', this.onClickCheckbox); + } + componentWillReceiveProps(nextProps) { if (!this.$rendered) { return; @@ -89,6 +98,27 @@ class Preview extends Component { return this.props.content !== nextProps.content || this.props.template !== nextProps.template; } + componentDidUpdate() { + const checkboxes = this.$rendered.querySelectorAll('.task-list-item-checkbox'); + + let index = 0; + [].forEach.call(checkboxes, (cb) => { + cb.setAttribute('data-task-list-item-index', index++); + }); + } + + componentWillUnmount() { + this.$rendered.removeEventListener('click', this.onClickCheckbox); + } + + onClickCheckbox(e) { + const target = e.target; + + if (target.hasAttribute('data-task-list-item-index')) { + this.props.onClickCheckbox(target.getAttribute('data-task-list-item-index')); + } + } + /** * A chunk is a logical group of tokens * We build chunks from token's level and nesting properties @@ -186,6 +216,7 @@ Preview.propTypes = { template: PropTypes.string.isRequired, position: PropTypes.number.isRequired, previewLoader: PropTypes.func.isRequired, + onClickCheckbox: PropTypes.func.isRequired, }; Preview.defaultProps = { diff --git a/app/components/loaders/Preview.jsx b/app/components/loaders/Preview.jsx index 563fb6e2..dc1e5275 100644 --- a/app/components/loaders/Preview.jsx +++ b/app/components/loaders/Preview.jsx @@ -23,6 +23,7 @@ export default () => new Promise(resolve => { require('markdown-it-classy'), ], markdownItContainer: require('markdown-it-container'), + markdownItTaskLists: require('markdown-it-task-lists'), emojione: require('emojione'), }); }); diff --git a/app/modules/__tests__/documents-test.js b/app/modules/__tests__/documents-test.js index a9d4a24f..4d9e245a 100644 --- a/app/modules/__tests__/documents-test.js +++ b/app/modules/__tests__/documents-test.js @@ -276,4 +276,119 @@ describe('modules/documents', () => { expect(triggeredActions[1].type).to.equal(actions.LOAD_DEFAULT); }); }); + + describe('toggleTaskListItem()', () => { + it('should check a task item in the current\’s document content', () => { + const doc = new Document({ + content: `Hello\n\n- [ ] item 1`, + }); + + const index = 0; + const state = reducer({ current: doc }, actions.toggleTaskListItem(index)); + + expect(state.current.get('content')).to.equal( + 'Hello\n\n- [x] item 1' + ); + }); + + it('should uncheck a checked task item in the current\’s document content', () => { + const doc = new Document({ + content: `Hello\n\n- [x] item 1`, + }); + + const index = 0; + const state = reducer({ current: doc }, actions.toggleTaskListItem(index)); + + expect(state.current.get('content')).to.equal( + 'Hello\n\n- [ ] item 1' + ); + }); + + it('should work with capital X', () => { + const doc = new Document({ + content: `Hello\n\n- [X] item 1`, + }); + + const index = 0; + const state = reducer({ current: doc }, actions.toggleTaskListItem(index)); + + expect(state.current.get('content')).to.equal( + 'Hello\n\n- [ ] item 1' + ); + }); + + it('should deal with many checkboxes', () => { + const doc = new Document({ + content: `Hello: + +- [ ] a bigger project + - [ ] first subtask #1234 + - [x] follow up subtask #4321 + - [ ] final subtask cc @mention +- [ ] a separate task` + }); + + const index = 2; + const state = reducer({ current: doc }, actions.toggleTaskListItem(index)); + + expect(state.current.get('content')).to.equal(`Hello: + +- [ ] a bigger project + - [ ] first subtask #1234 + - [ ] follow up subtask #4321 + - [ ] final subtask cc @mention +- [ ] a separate task` + ); + }); + + it('should deal with many checkboxes (2)', () => { + const doc = new Document({ + content: `Hello: + +- [ ] a bigger project + - [ ] first subtask #1234 + - [X] follow up subtask #4321 + - [ ] final subtask cc @mention + +- [ ] a separate task + - [ ] first subtask #1234 + - [X] follow up subtask #4321` + }); + + const index = 6; + const state = reducer({ current: doc }, actions.toggleTaskListItem(index)); + + expect(state.current.get('content')).to.equal(`Hello: + +- [ ] a bigger project + - [ ] first subtask #1234 + - [X] follow up subtask #4321 + - [ ] final subtask cc @mention + +- [ ] a separate task + - [ ] first subtask #1234 + - [ ] follow up subtask #4321` + ); + }); + + it('should not change the current document if there is no task list item', () => { + const doc = new Document({ content: 'Hello' }); + + const index = 123; + const state = reducer({ current: doc }, actions.toggleTaskListItem(index)); + + expect(state.current.get('content')).to.equal('Hello'); + expect(state.current.get('last_modified_locally')).to.be.null; + }); + + it('should not do anything if it is not strictly a task list item', () => { + const doc = new Document({ content: '[ ] foo' }); + + const index = 0; + const state = reducer({ current: doc }, actions.toggleTaskListItem(index)); + + expect(state.current.get('content')).to.equal('[ ] foo'); + expect(state.current.get('last_modified_locally')).to.be.null; + }); + }); }); diff --git a/app/modules/documents.js b/app/modules/documents.js index c52f0902..e66075bf 100644 --- a/app/modules/documents.js +++ b/app/modules/documents.js @@ -11,6 +11,7 @@ export const LOAD_SUCCESS = 'monod/documents/LOAD_SUCCESS'; export const UPDATE_TEMPLATE = 'monod/documents/UPDATE_TEMPLATE'; export const UPDATE_CONTENT = 'monod/documents/UPDATE_CONTENT'; export const UPDATE_CURRENT_DOCUMENT = 'monod/documents/UPDATE_CURRENT_DOCUMENT'; +export const TOGGLE_TASK_LIST_ITEM = 'monod/documents/TOGGLE_TASK_LIST_ITEM'; // Action Creators export function loadDefault() { @@ -108,6 +109,10 @@ export function serverUnreachable() { }; } +export function toggleTaskListItem(index) { + return { type: TOGGLE_TASK_LIST_ITEM, index }; +} + // Reducer const initialState = { current: new Document(), @@ -132,8 +137,40 @@ function doUpdateTemplate(state, action) { }; } +function doClickOnTask(state, action) { + const content = state.current.get('content'); + + let index = 0; + const updatedContent = content.replace(/\- \[[x| ]\] /gi, (match) => { + if (action.index !== index++) { + return match; + } + + if (/x/i.test(match)) { + return '- [ ] '; + } + + return '- [x] '; + }); + + if (content === updatedContent) { + return state; + } + + return { + ...state, + current: state.current + .set('content', updatedContent) + .set('last_modified_locally', Date.now()), + forceUpdate: true, + }; +} + export default function reducer(state = initialState, action = {}) { switch (action.type) { + case TOGGLE_TASK_LIST_ITEM: + return doClickOnTask(state, action); + case UPDATE_CURRENT_DOCUMENT: return { ...state, diff --git a/app/scss/components/_preview.scss b/app/scss/components/_preview.scss index d2df754d..f40a7ef1 100644 --- a/app/scss/components/_preview.scss +++ b/app/scss/components/_preview.scss @@ -136,5 +136,17 @@ bottom: 43px; } } + + .task-list { + list-style-type: none; + + input[type=checkbox] { + margin: 0; + } + } + + span > .task-list { + margin-left: 0; + } } } diff --git a/doc/images/task-lists.gif b/doc/images/task-lists.gif new file mode 100644 index 00000000..a0706004 Binary files /dev/null and b/doc/images/task-lists.gif differ diff --git a/doc/writing.md b/doc/writing.md index 0e7b1e98..de845ce1 100644 --- a/doc/writing.md +++ b/doc/writing.md @@ -33,6 +33,7 @@ extensions. * [Subscript](#subscript) * [Superscript](#superscript) * [Tables](#tables) +* [Task lists](#task-lists) ### Abbreviations @@ -174,6 +175,23 @@ Monod supports (data) tables: Note that it is possible to choose column alignment by specifying `:` either on the left, right or both sides of the horizontal separators. +### Task Lists + +Task lists are lists with items marked as either `[ ]` or `[x]` (incomplete or +complete). For example: + +``` +- [ ] a task list item + - [ ] a sub item +- [x] a task list item that is completed +``` + +This renders as a list of checkboxes. From here, you can either modify the +content of your Monod document, or directly check or uncheck the boxes in the +preview panel, and the text will automatically update: + +![](images/task-lists.gif) + ## YAML Front-Matter diff --git a/package.json b/package.json index 507b3be8..b4ff7d04 100644 --- a/package.json +++ b/package.json @@ -70,11 +70,13 @@ "markdown-it-modify-token": "^1.0.2", "markdown-it-sub": "^1.0.0", "markdown-it-sup": "^1.0.0", + "markdown-it-task-lists": "^1.4.1", "mocha": "^2.4.5", "mocha-circleci-reporter": "0.0.2", "node-sass": "^3.4.2", "nyc": "^8.1.0", "offline-plugin": "github:willdurand/offline-plugin#monod", + "raven-js": "^3.4.1", "react": "^15.3.0", "react-addons-test-utils": "^15.3.0", "react-dom": "^15.3.0", @@ -98,8 +100,7 @@ "uuid": "^2.0.1", "webpack": "^1.12.14", "webpack-dev-server": "^1.14.1", - "webpack-merge": "^0.14.1", - "raven-js": "^3.4.1" + "webpack-merge": "^0.14.1" }, "dependencies": { "body-parser": "^1.15.0",