Skip to content

Commit f1023e4

Browse files
authored
Merge pull request #141 from Codeminer42/story_links
Local story links
2 parents fc0caf0 + df0fef9 commit f1023e4

File tree

11 files changed

+378
-66
lines changed

11 files changed

+378
-66
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
- Redirect automatically to project#index after the user authentication, when there is only one team on enrollments
1414
- **Increase decimal precision from Stories position**
1515
- Change story controls to react component
16+
- Links to stories within the same project can be added to a story description.
1617

1718
## [1.1.3] - 2017-03-30
1819
### Changed
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import React from 'react';
2+
import hoverTemplate from 'templates/story_hover.ejs';
3+
import noteTemplate from 'templates/note.ejs';
4+
5+
const STORY_STATE_ICONS = {
6+
unstarted: 'access_time',
7+
started: 'check_box_outline_blank',
8+
finished: 'check_box',
9+
delivered: 'hourglass_empty',
10+
rejected: 'close',
11+
accepted: 'done'
12+
};
13+
14+
export default class StoryLink extends React.Component {
15+
constructor(props) {
16+
super(props);
17+
18+
this.handleClick = this.handleClick.bind(this);
19+
}
20+
21+
handleClick() {
22+
const { story } = this.props;
23+
document.getElementById(`story-${story.get('id')}`).scrollIntoView();
24+
story && _.each(story.views, view => view.highlight());
25+
}
26+
27+
renderIcon(storyState) {
28+
return (
29+
<i className={`mi story-link-icon ${storyState}`}>
30+
{STORY_STATE_ICONS[storyState]}
31+
</i>
32+
);
33+
}
34+
35+
render() {
36+
const { story } = this.props;
37+
const storyState = story.get('state');
38+
const id = story.get('id');
39+
const popoverContent = hoverTemplate({
40+
story: story,
41+
noteTemplate: noteTemplate
42+
});
43+
44+
return (
45+
<a className={`story-link popover-activate ${storyState}`}
46+
data-content={popoverContent}
47+
data-original-title={story.get('title')}
48+
id={`story-link-${id}`}
49+
onClick={this.handleClick}>
50+
{ `#${id}` }
51+
{ (storyState !== 'unscheduled') && this.renderIcon(storyState) }
52+
</a>
53+
);
54+
}
55+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import React from 'react';
2+
import Parser from 'html-react-parser'
3+
import memoize from 'memoizee';
4+
5+
import StoryLink from 'components/stories/StoryLink';
6+
7+
const editButton = isReadonly =>
8+
<input
9+
className={!isReadonly && 'edit-description'}
10+
disabled={isReadonly}
11+
value={I18n.t('edit')}
12+
type="button" />
13+
14+
const replaceStoryLink = (domNode, linkedStories) => {
15+
if (!domNode.attribs || !domNode.attribs['data-story-id']) return;
16+
const id = domNode.attribs['data-story-id'];
17+
const story = linkedStories[id];
18+
return ( <StoryLink story={story} key={id} /> );
19+
}
20+
21+
const StoryDescription = ({ description, isReadonly, linkedStories }) => {
22+
const isEmpty = (!description || !description.length);
23+
description = Parser(description, { replace: domNode =>
24+
replaceStoryLink(domNode, linkedStories)
25+
});
26+
27+
return (isEmpty)
28+
? editButton(isReadonly)
29+
: <div className='description'>
30+
{ description }
31+
</div>
32+
}
33+
34+
export default memoize(StoryDescription);

app/assets/javascripts/views/form_view.js

Lines changed: 2 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ module.exports = Backbone.View.extend({
2121
},
2222

2323
textArea: function(name) {
24-
var el = this.make('textarea', {name: name, class: 'form-control' }, this.model.get(name));
25-
$(el).attr('style', 'height:100px;overflow-y:hidden;');
24+
var el = this.make('textarea', {name: name, class: `form-control ${name}-textarea` }, this.model.get(name));
25+
$(el).attr('style', 'min-height:100px;');
2626
$(el).on('input', function () {
2727
this.style.height = 'auto';
2828
this.style.height = (this.scrollHeight) + 'px';
@@ -116,21 +116,6 @@ module.exports = Backbone.View.extend({
116116
return el;
117117
},
118118

119-
submit: function() {
120-
var el = this.make('input', {class: "submit", type: "button", value: I18n.t('save')});
121-
return el;
122-
},
123-
124-
destroy: function() {
125-
var el = this.make('input', {class: "destroy", type: "button", value: I18n.t('delete')});
126-
return el;
127-
},
128-
129-
cancel: function() {
130-
var el = this.make('input', {class: "cancel", type: "button", value: I18n.t('cancel')});
131-
return el;
132-
},
133-
134119
bindElementToAttribute: function(el, name, eventType) {
135120
var that = this;
136121
eventType = typeof(eventType) != 'undefined' ? eventType : "change";

app/assets/javascripts/views/story_view.js

Lines changed: 53 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React from 'react';
22
import ReactDOM from 'react-dom';
33
import StoryControls from 'components/story/StoryControls';
4+
import StoryDescription from 'components/story/StoryDescription';
45

56
var Clipboard = require('clipboard');
67

@@ -13,12 +14,15 @@ var NoteView = require('./note_view');
1314
var TaskForm = require('./task_form');
1415
var TaskView = require('./task_view');
1516

17+
const LOCAL_STORY_REGEXP = /(?!\s|\b)(#\d+)(?!\w)/g;
18+
1619
module.exports = FormView.extend({
1720

1821
template: require('templates/story.ejs'),
1922
alert: require('templates/alert.ejs'),
2023

2124
tagName: 'div',
25+
linkedStories: {},
2226

2327
initialize: function(options) {
2428
_.extend(this, _.pick(options, "isSearchResult"));
@@ -326,7 +330,11 @@ module.exports = FormView.extend({
326330
this.model.clear();
327331
},
328332

329-
editDescription: function() {
333+
editDescription: function(ev) {
334+
const $target = $(ev.target);
335+
if ($target.hasClass('story-link') || $target.hasClass('story-link-icon'))
336+
return;
337+
330338
this.model.set({editingDescription: true});
331339
this.render();
332340
},
@@ -358,7 +366,7 @@ module.exports = FormView.extend({
358366

359367
if (this.id != undefined) {
360368
var $wrapper = $(this.make('div', {class: 'col-xs-12 form-group input-group input-group-sm', id: inputId}));
361-
var inputId = 'story-link-' + this.id;
369+
var inputId = 'story-uri-' + this.id;
362370

363371
$wrapper.append(this.make('input', {
364372
id: inputId,
@@ -378,6 +386,13 @@ module.exports = FormView.extend({
378386
$(btn).html('<img src="/clippy.svg" alt="Copy to clipboard" width="14px">');
379387
$btnWrapper.append(btn);
380388

389+
btn = this.make('button', {
390+
class: 'btn btn-default btn-clipboard-id btn-clipboard',
391+
'data-clipboard-text': '#'+this.id,
392+
type: 'button'
393+
}, 'ID');
394+
$btnWrapper.append(btn);
395+
381396
// Story history button
382397
btn = this.make('button', {class: 'btn btn-default toggle-history'})
383398
$(btn).html('<i class="mi md-18">history</i>');
@@ -490,7 +505,7 @@ module.exports = FormView.extend({
490505
this.$el.append(
491506
this.makeFormControl(function(div) {
492507
$(div).append(this.label("description", I18n.t('activerecord.attributes.story.description')));
493-
$(div).append('<br/>');
508+
494509
if(this.model.isNew() || this.model.get('editingDescription')) {
495510
var textarea = this.textArea("description");
496511
$(textarea).atwho({
@@ -499,21 +514,8 @@ module.exports = FormView.extend({
499514
});
500515
$(div).append(textarea);
501516
} else {
502-
var description = this.make('div');
503-
$(description).addClass('description');
504-
$(description).html(
505-
window.md.makeHtml(this.model.escape('description'))
506-
);
507-
$(div).append(description);
508-
if (!this.model.get('description') || 0 === this.model.get('description').length) {
509-
$(description).after(
510-
this.make('input', {
511-
class: this.isReadonly() ? '' : 'edit-description',
512-
type: 'button',
513-
value: I18n.t('edit')
514-
})
515-
);
516-
}
517+
var $description = $('<div class="description-wrapper"><div>');
518+
$(div).append($description);
517519
}
518520
})
519521
);
@@ -564,6 +566,28 @@ module.exports = FormView.extend({
564566
/>,
565567
this.$('[data-story-controls]').get(0)
566568
);
569+
570+
const descriptionContainer = this.$('.description-wrapper')[0];
571+
if (descriptionContainer) {
572+
ReactDOM.render(
573+
<StoryDescription
574+
linkedStories={this.linkedStories}
575+
isReadonly={this.isReadonly()}
576+
description={this.parseDescription()} />,
577+
descriptionContainer
578+
);
579+
}
580+
},
581+
582+
parseDescription: function() {
583+
const description = window.md.makeHtml(this.model.escape('description')) || '';
584+
var id, story;
585+
return description.replace(LOCAL_STORY_REGEXP, story_id => {
586+
id = story_id.substring(1);
587+
story = this.model.collection.get(id);
588+
this.linkedStories[id] = story;
589+
return (story) ? `<a data-story-id='${id}'></a>` : story_id;
590+
});
567591
},
568592

569593
setClassName: function() {
@@ -697,22 +721,18 @@ module.exports = FormView.extend({
697721

698722
// FIXME Move to separate view
699723
hoverBox: function() {
700-
var view = this;
701-
this.$el.find('.popover-activate').popover({
702-
title: function() {
703-
return view.model.get("title");
704-
},
705-
content: function() {
706-
return require('templates/story_hover.ejs')({
707-
story: view.model,
724+
if (!this.model.isNew()) {
725+
this.$el.find('.popover-activate').popover({
726+
delay: 200, // A small delay to stop the popovers triggering whenever the mouse is moving around
727+
html: true,
728+
trigger: 'hover',
729+
title: () => this.model.get("title"),
730+
content: () => require('templates/story_hover.ejs')({
731+
story: this.model,
708732
noteTemplate: require('templates/note.ejs')
709-
});
710-
},
711-
// A small delay to stop the popovers triggering whenever the mouse is moving around
712-
delay: 200,
713-
html: true,
714-
trigger: 'hover'
715-
});
733+
})
734+
});
735+
}
716736
},
717737

718738
removeHoverbox: function() {

0 commit comments

Comments
 (0)