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