Use mapActions, mapGetters and mapMutations for components

This commit is contained in:
Filipa Lacerda 2017-07-26 12:02:01 +01:00
parent 4e81ad2ab9
commit ffef16690c
16 changed files with 862 additions and 816 deletions

View File

@ -1,6 +1,7 @@
<script> <script>
/* global Flash */ /* global Flash */
import { mapActions } from 'vuex';
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
import markdownField from '../../vue_shared/components/markdown/field.vue'; import markdownField from '../../vue_shared/components/markdown/field.vue';
import issueNoteSignedOutWidget from './issue_note_signed_out_widget.vue'; import issueNoteSignedOutWidget from './issue_note_signed_out_widget.vue';
@ -60,6 +61,9 @@
}, },
}, },
methods: { methods: {
...mapActions([
'saveNote'
]),
handleSave(withIssueAction) { handleSave(withIssueAction) {
if (this.note.length) { if (this.note.length) {
const noteData = { const noteData = {
@ -79,7 +83,7 @@
noteData.data.note.type = constants.DISCUSSION_NOTE; noteData.data.note.type = constants.DISCUSSION_NOTE;
} }
this.$store.dispatch('saveNote', noteData) this.saveNote(noteData)
.then((res) => { .then((res) => {
if (res.errors) { if (res.errors) {
if (res.errors.commands_only) { if (res.errors.commands_only) {

View File

@ -1,108 +1,112 @@
<script> <script>
/* global Flash */ /* global Flash */
import { mapActions } from 'vuex';
import { TOGGLE_DISCUSSION } from '../stores/mutation_types';
import { SYSTEM_NOTE } from '../constants';
import issueNote from './issue_note.vue';
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
import issueNoteHeader from './issue_note_header.vue';
import issueNoteActions from './issue_note_actions.vue';
import issueNoteSignedOutWidget from './issue_note_signed_out_widget.vue';
import issueNoteEditedText from './issue_note_edited_text.vue';
import issueNoteForm from './issue_note_form.vue';
import placeholderNote from './issue_placeholder_note.vue';
import placeholderSystemNote from './issue_placeholder_system_note.vue';
import issueNote from './issue_note.vue'; export default {
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; props: {
import issueNoteHeader from './issue_note_header.vue'; note: {
import issueNoteActions from './issue_note_actions.vue'; type: Object,
import issueNoteSignedOutWidget from './issue_note_signed_out_widget.vue'; required: true,
import issueNoteEditedText from './issue_note_edited_text.vue'; },
import issueNoteForm from './issue_note_form.vue';
import placeholderNote from './issue_placeholder_note.vue';
import placeholderSystemNote from './issue_placeholder_system_note.vue';
export default {
props: {
note: {
type: Object,
required: true,
}, },
}, data() {
data() { return {
return { newNotePath: window.gl.issueData.create_note_path,
newNotePath: window.gl.issueData.create_note_path, isReplying: false,
isReplying: false,
};
},
components: {
issueNote,
userAvatarLink,
issueNoteHeader,
issueNoteActions,
issueNoteSignedOutWidget,
issueNoteEditedText,
issueNoteForm,
placeholderNote,
placeholderSystemNote,
},
computed: {
discussion() {
return this.note.notes[0];
},
author() {
return this.discussion.author;
},
canReply() {
return window.gl.issueData.current_user.can_create_note;
},
},
methods: {
componentName(note) {
if (note.isPlaceholderNote) {
if (note.placeholderType === 'systemNote') {
return placeholderSystemNote;
}
return placeholderNote;
}
return issueNote;
},
componentData(note) {
return note.isPlaceholderNote ? note.notes[0] : note;
},
toggleDiscussion() {
this.$store.commit('toggleDiscussion', {
discussionId: this.note.id,
});
},
showReplyForm() {
this.isReplying = true;
},
cancelReplyForm(shouldConfirm) {
if (shouldConfirm && this.$refs.noteForm.isDirty) {
const msg = 'Are you sure you want to cancel creating this comment?';
// eslint-disable-next-line no-alert
const isConfirmed = confirm(msg);
if (!isConfirmed) {
return;
}
}
this.isReplying = false;
},
saveReply({ note }) {
const replyData = {
endpoint: this.newNotePath,
flashContainer: this.$el,
data: {
in_reply_to_discussion_id: this.note.reply_id,
target_type: 'issue',
target_id: this.discussion.noteable_id,
note: { note },
full_data: true,
},
}; };
this.$store.dispatch('saveNote', replyData)
.then(() => {
this.isReplying = false;
})
.catch(() => {
Flash('Something went wrong while adding your reply. Please try again.');
});
}, },
}, components: {
}; issueNote,
userAvatarLink,
issueNoteHeader,
issueNoteActions,
issueNoteSignedOutWidget,
issueNoteEditedText,
issueNoteForm,
placeholderNote,
placeholderSystemNote,
},
computed: {
discussion() {
return this.note.notes[0];
},
author() {
return this.discussion.author;
},
canReply() {
return window.gl.issueData.current_user.can_create_note;
},
},
methods: {
...mapActions([
'saveNote',
]),
...mapMutations({
toggleDiscussion: TOGGLE_DISCUSSION,
}),
componentName(note) {
if (note.isPlaceholderNote) {
if (note.placeholderType === SYSTEM_NOTE) {
return placeholderSystemNote;
}
return placeholderNote;
}
return issueNote;
},
componentData(note) {
return note.isPlaceholderNote ? note.notes[0] : note;
},
toggleDiscussion() {
this.toggleDiscussion({ discussionId: this.note.id });
},
showReplyForm() {
this.isReplying = true;
},
cancelReplyForm(shouldConfirm) {
if (shouldConfirm && this.$refs.noteForm.isDirty) {
const msg = 'Are you sure you want to cancel creating this comment?';
// eslint-disable-next-line no-alert
const isConfirmed = confirm(msg);
if (!isConfirmed) {
return;
}
}
this.isReplying = false;
},
saveReply({ note }) {
const replyData = {
endpoint: this.newNotePath,
flashContainer: this.$el,
data: {
in_reply_to_discussion_id: this.note.reply_id,
target_type: 'issue',
target_id: this.discussion.noteable_id,
note: { note },
full_data: true,
},
};
this.saveNote(replyData)
.then(() => {
this.isReplying = false;
})
.catch(() => Flash('Something went wrong while adding your reply. Please try again.'));
},
},
};
</script> </script>
<template> <template>
@ -132,8 +136,7 @@ export default {
:edited-at="note.last_updated_at" :edited-at="note.last_updated_at"
:edited-by="note.last_updated_by" :edited-by="note.last_updated_by"
actionText="Last updated" actionText="Last updated"
className="discussion-headline-light js-discussion-headline" className="discussion-headline-light js-discussion-headline" />
/>
</div> </div>
</div> </div>
<div <div
@ -162,7 +165,8 @@ export default {
saveButtonTitle="Comment" saveButtonTitle="Comment"
:update-handler="saveReply" :update-handler="saveReply"
:cancel-handler="cancelReplyForm" :cancel-handler="cancelReplyForm"
ref="noteForm" /> ref="noteForm"
/>
<issue-note-signed-out-widget v-if="!canReply" /> <issue-note-signed-out-widget v-if="!canReply" />
</div> </div>
</div> </div>

View File

@ -1,116 +1,118 @@
<script> <script>
/* global Flash */ /* global Flash */
import { mapGetters } from 'vuex'; import { mapGetters, mapActions } from 'vuex';
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
import issueNoteHeader from './issue_note_header.vue'; import issueNoteHeader from './issue_note_header.vue';
import issueNoteActions from './issue_note_actions.vue'; import issueNoteActions from './issue_note_actions.vue';
import issueNoteBody from './issue_note_body.vue'; import issueNoteBody from './issue_note_body.vue';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
export default { export default {
props: { props: {
note: { note: {
type: Object, type: Object,
required: true, required: true,
},
}, },
}, data() {
data() {
return {
isEditing: false,
isDeleting: false,
};
},
components: {
userAvatarLink,
issueNoteHeader,
issueNoteActions,
issueNoteBody,
},
computed: {
...mapGetters([
'targetNoteHash',
]),
author() {
return this.note.author;
},
classNameBindings() {
return { return {
'is-editing': this.isEditing, isEditing: false,
'disabled-content': this.isDeleting, isDeleting: false,
'js-my-note': this.author.id === window.gon.current_user_id,
target: this.targetNoteHash === this.noteAnchorId,
}; };
}, },
canReportAsAbuse() { components: {
return this.note.report_abuse_path && this.author.id !== window.gon.current_user_id; userAvatarLink,
issueNoteHeader,
issueNoteActions,
issueNoteBody,
}, },
noteAnchorId() { computed: {
return `note_${this.note.id}`; ...mapGetters([
'targetNoteHash',
]),
author() {
return this.note.author;
},
classNameBindings() {
return {
'is-editing': this.isEditing,
'disabled-content': this.isDeleting,
'js-my-note': this.author.id === window.gon.current_user_id,
target: this.targetNoteHash === this.noteAnchorId,
};
},
canReportAsAbuse() {
return this.note.report_abuse_path && this.author.id !== window.gon.current_user_id;
},
noteAnchorId() {
return `note_${this.note.id}`;
},
}, },
}, methods: {
methods: { ...mapActions([
editHandler() { 'deleteNote',
this.isEditing = true; 'updateNote',
}, 'scrollToNoteIfNeeded',
deleteHandler() { ]),
const msg = 'Are you sure you want to delete this list?'; editHandler() {
const isConfirmed = confirm(msg); // eslint-disable-line
if (isConfirmed) {
this.isDeleting = true;
this.$store
.dispatch('deleteNote', this.note)
.then(() => {
this.isDeleting = false;
})
.catch(() => {
new Flash('Something went wrong while deleting your note. Please try again.'); // eslint-disable-line
this.isDeleting = false;
});
}
},
formUpdateHandler(note) {
const data = {
endpoint: this.note.path,
note: {
full_data: true,
target_type: 'issue',
target_id: this.note.noteable_id,
note,
},
};
this.$store.dispatch('updateNote', data)
.then(() => {
this.isEditing = false;
$(this.$refs.noteBody.$el).renderGFM();
})
.catch(() => {
Flash('Something went wrong while editing your comment. Please try again.');
});
},
formCancelHandler(shouldConfirm) {
if (shouldConfirm && this.$refs.noteBody.$refs.noteForm.isDirty) {
const msg = 'Are you sure you want to cancel editing this comment?';
const isConfirmed = confirm(msg); // eslint-disable-line
if (!isConfirmed) {
return;
}
}
this.isEditing = false;
},
},
created() {
eventHub.$on('enterEditMode', ({ noteId }) => {
if (noteId === this.note.id) {
this.isEditing = true; this.isEditing = true;
this.$store.dispatch('scrollToNoteIfNeeded', $(this.$el)); },
} deleteHandler() {
}); const msg = 'Are you sure you want to delete this list?';
}, const isConfirmed = confirm(msg); // eslint-disable-line
};
if (isConfirmed) {
this.isDeleting = true;
this.deleteNote(this.note)
.then(() => {
this.isDeleting = false;
})
.catch(() => {
Flash('Something went wrong while deleting your note. Please try again.');
this.isDeleting = false;
});
}
},
formUpdateHandler(note) {
const data = {
endpoint: this.note.path,
note: {
full_data: true,
target_type: 'issue',
target_id: this.note.noteable_id,
note,
},
};
this.updateNote(data)
.then(() => {
this.isEditing = false;
$(this.$refs.noteBody.$el).renderGFM();
})
.catch(() => Flash('Something went wrong while editing your comment. Please try again.'));
},
formCancelHandler(shouldConfirm) {
if (shouldConfirm && this.$refs.noteBody.$refs.noteForm.isDirty) {
const msg = 'Are you sure you want to cancel editing this comment?';
const isConfirmed = confirm(msg); // eslint-disable-line
if (!isConfirmed) {
return;
}
}
this.isEditing = false;
},
},
created() {
eventHub.$on('enterEditMode', ({ noteId }) => {
if (noteId === this.note.id) {
this.isEditing = true;
this.scrollToNoteIfNeeded($(this.$el));
}
});
},
};
</script> </script>
<template> <template>
@ -124,7 +126,8 @@ export default {
:link-href="author.path" :link-href="author.path"
:img-src="author.avatar_url" :img-src="author.avatar_url"
:img-alt="author.name" :img-alt="author.name"
:img-size="40" /> :img-size="40"
/>
</div> </div>
<div class="timeline-content"> <div class="timeline-content">
<div class="note-header"> <div class="note-header">
@ -132,7 +135,8 @@ export default {
:author="author" :author="author"
:created-at="note.created_at" :created-at="note.created_at"
:note-id="note.id" :note-id="note.id"
actionText="commented" /> actionText="commented"
/>
<issue-note-actions <issue-note-actions
:author-id="author.id" :author-id="author.id"
:note-id="note.id" :note-id="note.id"
@ -142,7 +146,8 @@ export default {
:can-report-as-abuse="canReportAsAbuse" :can-report-as-abuse="canReportAsAbuse"
:report-abuse-path="note.report_abuse_path" :report-abuse-path="note.report_abuse_path"
:edit-handler="editHandler" :edit-handler="editHandler"
:delete-handler="deleteHandler" /> :delete-handler="deleteHandler"
/>
</div> </div>
<issue-note-body <issue-note-body
:note="note" :note="note"
@ -150,7 +155,8 @@ export default {
:is-editing="isEditing" :is-editing="isEditing"
:form-update-handler="formUpdateHandler" :form-update-handler="formUpdateHandler"
:form-cancel-handler="formCancelHandler" :form-cancel-handler="formCancelHandler"
ref="noteBody" /> ref="noteBody"
/>
</div> </div>
</div> </div>
</li> </li>

View File

@ -1,68 +1,71 @@
<script> <script>
import emojiSmiling from 'icons/_emoji_slightly_smiling_face.svg'; import emojiSmiling from 'icons/_emoji_slightly_smiling_face.svg';
import emojiSmile from 'icons/_emoji_smile.svg'; import emojiSmile from 'icons/_emoji_smile.svg';
import emojiSmiley from 'icons/_emoji_smiley.svg'; import emojiSmiley from 'icons/_emoji_smiley.svg';
import loadingIcon from '../../vue_shared/components/loadingIcon.vue'; import loadingIcon from '../../vue_shared/components/loading_icon.vue';
export default { export default {
props: { props: {
authorId: { authorId: {
type: Number, type: Number,
required: true, required: true,
},
noteId: {
type: Number,
required: true,
},
accessLevel: {
type: String,
required: false,
default: '',
},
reportAbusePath: {
type: String,
required: true,
},
canEdit: {
type: Boolean,
required: true,
},
canDelete: {
type: Boolean,
required: true,
},
canReportAsAbuse: {
type: Boolean,
required: true,
},
editHandler: {
type: Function,
required: true,
},
deleteHandler: {
type: Function,
required: true,
},
}, },
noteId: { data() {
type: Number, return {
required: true, emojiSmiling,
emojiSmile,
emojiSmiley,
};
}, },
accessLevel: { components: {
type: String, loadingIcon,
required: false,
default: '',
}, },
reportAbusePath: { computed: {
type: String, shouldShowActionsDropdown() {
required: true, return window.gon.current_user_id && (this.canEdit || this.canReportAsAbuse);
},
canAddAwardEmoji() {
return window.gon.current_user_id;
},
isAuthoredByMe() {
return this.authorId === window.gon.current_user_id;
},
}, },
canEdit: { };
type: Boolean,
required: true,
},
canDelete: {
type: Boolean,
required: true,
},
canReportAsAbuse: {
type: Boolean,
required: true,
},
editHandler: {
type: Function,
required: true,
},
deleteHandler: {
type: Function,
required: true,
},
},
data() {
return {
emojiSmiling,
emojiSmile,
emojiSmiley,
};
},
computed: {
shouldShowActionsDropdown() {
return window.gon.current_user_id && (this.canEdit || this.canReportAsAbuse);
},
canAddAwardEmoji() {
return window.gon.current_user_id;
},
isAuthoredByMe() {
return this.authorId === window.gon.current_user_id;
},
},
};
</script> </script>
<template> <template>
@ -82,13 +85,16 @@ export default {
<loading-icon /> <loading-icon />
<span <span
v-html="emojiSmiling" v-html="emojiSmiling"
class="link-highlight award-control-icon-neutral"></span> class="link-highlight award-control-icon-neutral">
</span>
<span <span
v-html="emojiSmiley" v-html="emojiSmiley"
class="link-highlight award-control-icon-positive"></span> class="link-highlight award-control-icon-positive">
</span>
<span <span
v-html="emojiSmile" v-html="emojiSmile"
class="link-highlight award-control-icon-super-positive"></span> class="link-highlight award-control-icon-super-positive">
</span>
</a> </a>
<div <div
v-if="shouldShowActionsDropdown" v-if="shouldShowActionsDropdown"
@ -101,7 +107,8 @@ export default {
data-container="body"> data-container="body">
<i <i
aria-hidden="true" aria-hidden="true"
class="fa fa-ellipsis-v icon"></i> class="fa fa-ellipsis-v icon">
</i>
</button> </button>
<ul class="dropdown-menu more-actions-dropdown dropdown-open-left"> <ul class="dropdown-menu more-actions-dropdown dropdown-open-left">
<template v-if="canEdit"> <template v-if="canEdit">

View File

@ -1,164 +1,166 @@
<script> <script>
/* global Flash */ /* global Flash */
import emojiSmiling from 'icons/_emoji_slightly_smiling_face.svg'; import { mapActions } from 'vuex';
import emojiSmile from 'icons/_emoji_smile.svg'; import emojiSmiling from 'icons/_emoji_slightly_smiling_face.svg';
import emojiSmiley from 'icons/_emoji_smiley.svg'; import emojiSmile from 'icons/_emoji_smile.svg';
import * as Emoji from '../../emoji'; import emojiSmiley from 'icons/_emoji_smiley.svg';
import * as Emoji from '../../emoji';
export default { export default {
props: { props: {
awards: { awards: {
type: Array, type: Array,
required: true, required: true,
},
toggleAwardPath: {
type: String,
required: true,
},
noteAuthorId: {
type: Number,
required: true,
},
noteId: {
type: Number,
required: true,
},
}, },
toggleAwardPath: { data() {
type: String, const userId = window.gon.current_user_id;
required: true,
},
noteAuthorId: {
type: Number,
required: true,
},
noteId: {
type: Number,
required: true,
},
},
data() {
const userId = window.gon.current_user_id;
return {
emojiSmiling,
emojiSmile,
emojiSmiley,
canAward: !!userId,
myUserId: userId,
};
},
computed: {
// `this.awards` is an array with emojis but they are not grouped by emoji name. See below.
// [ { name: foo, user: user1 }, { name: bar, user: user1 }, { name: foo, user: user2 } ]
// This method will group emojis by their name as an Object. See below.
// {
// foo: [ { name: foo, user: user1 }, { name: foo, user: user2 } ],
// bar: [ { name: bar, user: user1 } ]
// }
// We need to do this otherwise we will render the same emoji over and over again.
groupedAwards() {
const awards = {};
const orderedAwards = {};
this.awards.forEach((award) => {
awards[award.name] = awards[award.name] || [];
awards[award.name].push(award);
});
// Always show thumbsup and thumbsdown first
const { thumbsup, thumbsdown } = awards;
if (thumbsup) {
orderedAwards.thumbsup = thumbsup;
delete awards.thumbsup;
}
if (thumbsdown) {
orderedAwards.thumbsdown = thumbsdown;
delete awards.thumbsdown;
}
// Because for-in forbidden
const keys = Object.keys(awards);
keys.forEach((key) => {
orderedAwards[key] = awards[key];
});
return orderedAwards;
},
isAuthoredByMe() {
return this.noteAuthorId === window.gon.current_user_id;
},
},
methods: {
getAwardHTML(name) {
return Emoji.glEmojiTag(name);
},
getAwardClassBindings(awardList, awardName) {
return { return {
active: this.amIAwarded(awardList), emojiSmiling,
disabled: !this.canInteractWithEmoji(awardList, awardName), emojiSmile,
emojiSmiley,
canAward: !!userId,
myUserId: userId,
}; };
}, },
canInteractWithEmoji(awardList, awardName) { computed: {
let isAllowed = true; // `this.awards` is an array with emojis but they are not grouped by emoji name. See below.
const restrictedEmojis = ['thumbsup', 'thumbsdown']; // [ { name: foo, user: user1 }, { name: bar, user: user1 }, { name: foo, user: user2 } ]
const { myUserId, noteAuthorId } = this; // This method will group emojis by their name as an Object. See below.
// {
// foo: [ { name: foo, user: user1 }, { name: foo, user: user2 } ],
// bar: [ { name: bar, user: user1 } ]
// }
// We need to do this otherwise we will render the same emoji over and over again.
groupedAwards() {
const awards = {};
const orderedAwards = {};
// Users can not add :+1: and :-1: to their notes this.awards.forEach((award) => {
if (myUserId === noteAuthorId && restrictedEmojis.indexOf(awardName) > -1) { awards[award.name] = awards[award.name] || [];
isAllowed = false; awards[award.name].push(award);
}
return this.canAward && isAllowed;
},
amIAwarded(awardList) {
const isAwarded = awardList.filter(award => award.user.id === this.myUserId);
return isAwarded.length;
},
awardTitle(awardsList) {
const amIAwarded = this.amIAwarded(awardsList);
const TOOLTIP_NAME_COUNT = amIAwarded ? 9 : 10;
let awardList = awardsList;
// Filter myself from list if I am awarded.
if (amIAwarded) {
awardList = awardList.filter(award => award.user.id !== this.myUserId);
}
// Get only 9-10 usernames to show in tooltip text.
const namesToShow = awardList.slice(0, TOOLTIP_NAME_COUNT).map(award => award.user.name);
// Get the remaining list to use in `and x more` text.
const remainingAwardList = awardList.slice(TOOLTIP_NAME_COUNT, awardList.length);
// Add myself to the begining of the list so title will start with You.
if (amIAwarded) {
namesToShow.unshift('You');
}
let title = '';
// We have 10+ awarded user, join them with comma and add `and x more`.
if (remainingAwardList.length) {
title = `${namesToShow.join(', ')}, and ${remainingAwardList.length} more.`;
} else if (namesToShow.length > 1) {
// Join all names with comma but not the last one, it will be added with and text.
title = namesToShow.slice(0, namesToShow.length - 1).join(', ');
// If we have more than 2 users we need an extra comma before and text.
title += namesToShow.length > 2 ? ',' : '';
title += ` and ${namesToShow.slice(-1)}`; // Append and text
} else { // We have only 2 users so join them with and.
title = namesToShow.join(' and ');
}
return title;
},
handleAward(awardName) {
const data = {
endpoint: this.toggleAwardPath,
noteId: this.noteId,
awardName,
};
this.$store.dispatch('toggleAward', data)
.then(() => {
$(this.$el).find('.award-control').tooltip('fixTitle');
})
.catch(() => {
Flash('Something went wrong on our end.');
}); });
// Always show thumbsup and thumbsdown first
const { thumbsup, thumbsdown } = awards;
if (thumbsup) {
orderedAwards.thumbsup = thumbsup;
delete awards.thumbsup;
}
if (thumbsdown) {
orderedAwards.thumbsdown = thumbsdown;
delete awards.thumbsdown;
}
// Because for-in forbidden
const keys = Object.keys(awards);
keys.forEach((key) => {
orderedAwards[key] = awards[key];
});
return orderedAwards;
},
isAuthoredByMe() {
return this.noteAuthorId === window.gon.current_user_id;
},
}, },
}, methods: {
}; ...mapActions([
'toggleAward',
]),
getAwardHTML(name) {
return Emoji.glEmojiTag(name);
},
getAwardClassBindings(awardList, awardName) {
return {
active: this.amIAwarded(awardList),
disabled: !this.canInteractWithEmoji(awardList, awardName),
};
},
canInteractWithEmoji(awardList, awardName) {
let isAllowed = true;
const restrictedEmojis = ['thumbsup', 'thumbsdown'];
const { myUserId, noteAuthorId } = this;
// Users can not add :+1: and :-1: to their own notes
if (myUserId === noteAuthorId && restrictedEmojis.indexOf(awardName) > -1) {
isAllowed = false;
}
return this.canAward && isAllowed;
},
amIAwarded(awardList) {
const isAwarded = awardList.filter(award => award.user.id === this.myUserId);
return isAwarded.length;
},
awardTitle(awardsList) {
const amIAwarded = this.amIAwarded(awardsList);
const TOOLTIP_NAME_COUNT = amIAwarded ? 9 : 10;
let awardList = awardsList;
// Filter myself from list if I am awarded.
if (amIAwarded) {
awardList = awardList.filter(award => award.user.id !== this.myUserId);
}
// Get only 9-10 usernames to show in tooltip text.
const namesToShow = awardList.slice(0, TOOLTIP_NAME_COUNT).map(award => award.user.name);
// Get the remaining list to use in `and x more` text.
const remainingAwardList = awardList.slice(TOOLTIP_NAME_COUNT, awardList.length);
// Add myself to the begining of the list so title will start with You.
if (amIAwarded) {
namesToShow.unshift('You');
}
let title = '';
// We have 10+ awarded user, join them with comma and add `and x more`.
if (remainingAwardList.length) {
title = `${namesToShow.join(', ')}, and ${remainingAwardList.length} more.`;
} else if (namesToShow.length > 1) {
// Join all names with comma but not the last one, it will be added with and text.
title = namesToShow.slice(0, namesToShow.length - 1).join(', ');
// If we have more than 2 users we need an extra comma before and text.
title += namesToShow.length > 2 ? ',' : '';
title += ` and ${namesToShow.slice(-1)}`; // Append and text
} else { // We have only 2 users so join them with and.
title = namesToShow.join(' and ');
}
return title;
},
handleAward(awardName) {
const data = {
endpoint: this.toggleAwardPath,
noteId: this.noteId,
awardName,
};
this.toggleAward(data)
.then(() => {
$(this.$el).find('.award-control').tooltip('fixTitle');
})
.catch(() => Flash('Something went wrong on our end.'));
},
},
};
</script> </script>
<template> <template>
@ -189,13 +191,16 @@ export default {
type="button"> type="button">
<span <span
v-html="emojiSmiling" v-html="emojiSmiling"
class="award-control-icon award-control-icon-neutral"></span> class="award-control-icon award-control-icon-neutral">
</span>
<span <span
v-html="emojiSmiley" v-html="emojiSmiley"
class="award-control-icon award-control-icon-positive"></span> class="award-control-icon award-control-icon-positive">
</span>
<span <span
v-html="emojiSmile" v-html="emojiSmile"
class="award-control-icon award-control-icon-super-positive"></span> class="award-control-icon award-control-icon-super-positive">
</span>
<i <i
aria-hidden="true" aria-hidden="true"
class="fa fa-spinner fa-spin award-control-icon award-control-icon-loading"></i> class="fa fa-spinner fa-spin award-control-icon award-control-icon-loading"></i>

View File

@ -1,70 +1,70 @@
<script> <script>
import issueNoteEditedText from './issue_note_edited_text.vue'; import issueNoteEditedText from './issue_note_edited_text.vue';
import issueNoteAwardsList from './issue_note_awards_list.vue'; import issueNoteAwardsList from './issue_note_awards_list.vue';
import issueNoteForm from './issue_note_form.vue'; import issueNoteForm from './issue_note_form.vue';
import TaskList from '../../task_list'; import TaskList from '../../task_list';
export default { export default {
props: { props: {
note: { note: {
type: Object, type: Object,
required: true, required: true,
},
canEdit: {
type: Boolean,
required: true,
},
isEditing: {
type: Boolean,
required: false,
default: false,
},
formUpdateHandler: {
type: Function,
required: true,
},
formCancelHandler: {
type: Function,
required: true,
},
}, },
canEdit: { components: {
type: Boolean, issueNoteEditedText,
required: true, issueNoteAwardsList,
issueNoteForm,
}, },
isEditing: { computed: {
type: Boolean, noteBody() {
required: false, return this.note.note;
default: false, },
}, },
formUpdateHandler: { methods: {
type: Function, renderGFM() {
required: true, $(this.$refs['note-body']).renderGFM();
}, },
formCancelHandler: { initTaskList() {
type: Function, if (this.canEdit) {
required: true, this.taskList = new TaskList({
}, dataType: 'note',
}, fieldName: 'note',
components: { selector: '.notes',
issueNoteEditedText, });
issueNoteAwardsList, }
issueNoteForm, },
}, handleFormUpdate() {
computed: { this.formUpdateHandler({
noteBody() { note: this.$refs.noteForm.note,
return this.note.note;
},
},
methods: {
renderGFM() {
$(this.$refs['note-body']).renderGFM();
},
initTaskList() {
if (this.canEdit) {
this.taskList = new TaskList({
dataType: 'note',
fieldName: 'note',
selector: '.notes',
}); });
} },
}, },
handleFormUpdate() { mounted() {
this.formUpdateHandler({ this.renderGFM();
note: this.$refs.noteForm.note, this.initTaskList();
});
}, },
}, updated() {
mounted() { this.initTaskList();
this.renderGFM(); },
this.initTaskList(); };
},
updated() {
this.initTaskList();
},
};
</script> </script>
<template> <template>

View File

@ -1,30 +1,30 @@
<script> <script>
import timeAgoTooltip from '../../vue_shared/components/time_ago_tooltip.vue'; import timeAgoTooltip from '../../vue_shared/components/time_ago_tooltip.vue';
export default { export default {
props: { props: {
actionText: { actionText: {
type: String, type: String,
required: true, required: true,
},
editedAt: {
type: String,
required: true,
},
editedBy: {
type: Object,
required: true,
},
className: {
type: String,
required: false,
default: 'edited-text',
},
}, },
editedAt: { components: {
type: String, timeAgoTooltip,
required: true,
}, },
editedBy: { };
type: Object,
required: true,
},
className: {
type: String,
required: false,
default: 'edited-text',
},
},
components: {
timeAgoTooltip,
},
};
</script> </script>
<template> <template>
@ -38,6 +38,7 @@ export default {
</a> </a>
<time-ago-tooltip <time-ago-tooltip
:time="editedAt" :time="editedAt"
tooltip-placement="bottom" /> tooltip-placement="bottom"
/>
</div> </div>
</template> </template>

View File

@ -1,88 +1,88 @@
<script> <script>
import markdownField from '../../vue_shared/components/markdown/field.vue'; import markdownField from '../../vue_shared/components/markdown/field.vue';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
export default { export default {
props: { props: {
noteBody: { noteBody: {
type: String, type: String,
required: false, required: false,
default: '', default: '',
},
noteId: {
type: Number,
required: false,
},
updateHandler: {
type: Function,
required: true,
},
cancelHandler: {
type: Function,
required: true,
},
saveButtonTitle: {
type: String,
required: false,
default: 'Save comment',
},
}, },
noteId: { data() {
type: Number, return {
required: false, initialNote: this.noteBody,
note: this.noteBody,
markdownPreviewUrl: gl.issueData.preview_note_path,
markdownDocsUrl: '',
conflictWhileEditing: false,
};
}, },
updateHandler: { components: {
type: Function, markdownField,
required: true,
}, },
cancelHandler: { computed: {
type: Function, isDirty() {
required: true, return this.initialNote !== this.note;
},
noteHash() {
return `#note_${this.noteId}`;
},
}, },
saveButtonTitle: { methods: {
type: String, handleUpdate() {
required: false, this.updateHandler({
default: 'Save comment', note: this.note,
}, });
}, },
data() { editMyLastNote() {
return { if (this.note === '') {
initialNote: this.noteBody, const discussion = $(this.$el).closest('.discussion-notes');
note: this.noteBody, const myLastNoteId = discussion.find('.js-my-note').last().attr('id');
markdownPreviewUrl: gl.issueData.preview_note_path,
markdownDocsUrl: '',
conflictWhileEditing: false,
};
},
components: {
markdownField,
},
computed: {
isDirty() {
return this.initialNote !== this.note;
},
noteHash() {
return `#note_${this.noteId}`;
},
},
methods: {
handleUpdate() {
this.updateHandler({
note: this.note,
});
},
editMyLastNote() {
if (this.note === '') {
const discussion = $(this.$el).closest('.discussion-notes');
const myLastNoteId = discussion.find('.js-my-note').last().attr('id');
if (myLastNoteId) { if (myLastNoteId) {
eventHub.$emit('enterEditMode', { eventHub.$emit('enterEditMode', {
noteId: parseInt(myLastNoteId.replace('note_', ''), 10), noteId: parseInt(myLastNoteId.replace('note_', ''), 10),
}); });
}
} }
} },
}, },
}, mounted() {
mounted() { const issuableDataEl = document.getElementById('js-issuable-app-initial-data');
const issuableDataEl = document.getElementById('js-issuable-app-initial-data'); const issueData = JSON.parse(issuableDataEl.innerHTML.replace(/&quot;/g, '"'));
const issueData = JSON.parse(issuableDataEl.innerHTML.replace(/&quot;/g, '"'));
this.markdownDocsUrl = issueData.markdownDocs; this.markdownDocsUrl = issueData.markdownDocs;
this.$refs.textarea.focus(); this.$refs.textarea.focus();
},
watch: {
noteBody() {
if (this.note === this.initialNote) {
this.note = this.noteBody;
} else {
this.conflictWhileEditing = true;
}
}, },
}, watch: {
}; noteBody() {
if (this.note === this.initialNote) {
this.note = this.noteBody;
} else {
this.conflictWhileEditing = true;
}
},
},
};
</script> </script>
<template> <template>

View File

@ -1,66 +1,71 @@
<script> <script>
import timeAgoTooltip from '../../vue_shared/components/time_ago_tooltip.vue'; import { mapMutations } from 'vuex';
import timeAgoTooltip from '../../vue_shared/components/time_ago_tooltip.vue';
import * as types from '../stores/mutation_types';
export default { export default {
props: { props: {
author: { author: {
type: Object, type: Object,
required: true, required: true,
},
createdAt: {
type: String,
required: true,
},
actionText: {
type: String,
required: false,
default: '',
},
actionTextHtml: {
type: String,
required: false,
default: '',
},
noteId: {
type: Number,
required: true,
},
includeToggle: {
type: Boolean,
required: false,
default: false,
},
toggleHandler: {
type: Function,
required: false,
},
}, },
createdAt: { data() {
type: String, return {
required: true, isExpanded: true,
};
}, },
actionText: { components: {
type: String, timeAgoTooltip,
required: false,
default: '',
}, },
actionTextHtml: { computed: {
type: String, toggleChevronClass() {
required: false, return this.isExpanded ? 'fa-chevron-up' : 'fa-chevron-down';
default: '', },
noteTimestampLink() {
return `#note_${this.noteId}`;
},
}, },
noteId: { methods: {
type: Number, ...mapMutations({
required: true, setTargetNoteHash: types.SET_TARGET_NOTE_HASH,
}),
handleToggle() {
this.isExpanded = !this.isExpanded;
this.toggleHandler();
},
updateTargetNoteHash() {
this.setTargetNoteHash(this.noteTimestampLink);
},
}, },
includeToggle: { };
type: Boolean,
required: false,
default: false,
},
toggleHandler: {
type: Function,
required: false,
},
},
data() {
return {
isExpanded: true,
};
},
components: {
timeAgoTooltip,
},
computed: {
toggleChevronClass() {
return this.isExpanded ? 'fa-chevron-up' : 'fa-chevron-down';
},
noteTimestampLink() {
return `#note_${this.noteId}`;
},
},
methods: {
handleToggle() {
this.isExpanded = !this.isExpanded;
this.toggleHandler();
},
updateTargetNoteHash() {
this.$store.commit('setTargetNoteHash', this.noteTimestampLink);
},
},
};
</script> </script>
<template> <template>
@ -81,13 +86,15 @@ export default {
<span <span
v-if="actionTextHtml" v-if="actionTextHtml"
v-html="actionTextHtml" v-html="actionTextHtml"
class="system-note-message"></span> class="system-note-message">
</span>
<a <a
:href="noteTimestampLink" :href="noteTimestampLink"
@click="updateTargetNoteHash"> @click="updateTargetNoteHash">
<time-ago-tooltip <time-ago-tooltip
:time="createdAt" :time="createdAt"
tooltipPlacement="bottom" /> tooltipPlacement="bottom"
/>
</a> </a>
</span> </span>
</span> </span>
@ -101,7 +108,8 @@ export default {
<i <i
:class="toggleChevronClass" :class="toggleChevronClass"
class="fa" class="fa"
aria-hidden="true"></i> aria-hidden="true">
</i>
Toggle discussion Toggle discussion
</button> </button>
</div> </div>

View File

@ -1,18 +1,18 @@
<script> <script>
export default { export default {
data() { data() {
return { return {
signInLink: '#', signInLink: '#',
}; };
}, },
mounted() { mounted() {
const wrapper = document.querySelector('.js-notes-wrapper'); const wrapper = document.querySelector('.js-notes-wrapper');
if (wrapper) { if (wrapper) {
this.signInLink = wrapper.dataset.newSessionPath; this.signInLink = wrapper.dataset.newSessionPath;
} }
}, },
}; };
</script> </script>
<template> <template>

View File

@ -1,119 +1,131 @@
<script> <script>
/* global Flash */ /* global Flash */
import Vue from 'vue'; import Vue from 'vue';
import Vuex from 'vuex'; import { mapGetters, mapActions, mapMutations } from 'vuex';
import VueResource from 'vue-resource'; import store from '../stores/';
import storeOptions from '../stores/issue_notes_store'; import * as constants from '../constants'
import eventHub from '../event_hub'; import * as types from '../stores/mutation_types';
import issueNote from './issue_note.vue'; import eventHub from '../event_hub';
import issueDiscussion from './issue_discussion.vue'; import issueNote from './issue_note.vue';
import issueSystemNote from './issue_system_note.vue'; import issueDiscussion from './issue_discussion.vue';
import issueCommentForm from './issue_comment_form.vue'; import issueSystemNote from './issue_system_note.vue';
import placeholderNote from './issue_placeholder_note.vue'; import issueCommentForm from './issue_comment_form.vue';
import placeholderSystemNote from './issue_placeholder_system_note.vue'; import placeholderNote from './issue_placeholder_note.vue';
import store from './store'; import placeholderSystemNote from './issue_placeholder_system_note.vue';
import loadingIcon from '../../vue_shared/components/loading_icon.vue';
export default { export default {
name: 'IssueNotes', name: 'IssueNotes',
store, store,
data() { data() {
return { return {
isLoading: true, isLoading: true,
}; };
}, },
components: { components: {
issueNote, issueNote,
issueDiscussion, issueDiscussion,
issueSystemNote, issueSystemNote,
issueCommentForm, issueCommentForm,
placeholderNote, loadingIcon,
placeholderSystemNote, placeholderNote,
}, placeholderSystemNote,
computed: { },
...Vuex.mapGetters([ computed: {
'notes', ...mapGetters([
'notesById', 'notes',
]), 'notesById',
}, ]),
methods: { },
componentName(note) { methods: {
if (note.isPlaceholderNote) { ...mapActions({
if (note.placeholderType === 'systemNote') { actionFetchNotes: 'fetchNotes',
return placeholderSystemNote; }),
...mapActions([
'poll',
'toggleAward',
'scrollToNoteIfNeeded',
]),
...mapMutations({
setLastFetchedAt: types.SET_LAST_FETCHED_AT,
setTargetNoteHash: types.SET_TARGET_NOTE_HASH,
}),
getComponentName(note) {
if (note.isPlaceholderNote) {
if (note.placeholderType === constants.SYSTEM_NOTE) {
return placeholderSystemNote;
}
return placeholderNote;
} else if (note.individual_note) {
return note.notes[0].system ? issueSystemNote : issueNote;
} }
return placeholderNote;
} else if (note.individual_note) {
return note.notes[0].system ? issueSystemNote : issueNote;
}
return issueDiscussion; return issueDiscussion;
}, },
componentData(note) { getComponentData(note) {
return note.individual_note ? note.notes[0] : note; return note.individual_note ? note.notes[0] : note;
}, },
fetchNotes() { fetchNotes() {
const { discussionsPath } = this.$el.parentNode.dataset; const { discussionsPath } = this.$el.parentNode.dataset;
this.$store.dispatch('fetchNotes', discussionsPath) this.actionFetchNotes(discussionsPath)
.then(() => { .then(() => {
this.isLoading = false; this.isLoading = false;
// Scroll to note if we have hash fragment in the page URL // Scroll to note if we have hash fragment in the page URL
Vue.nextTick(() => { Vue.nextTick(() => {
this.checkLocationHash(); this.checkLocationHash();
}); });
})
.catch(() => {
Flash('Something went wrong while fetching issue comments. Please try again.');
});
},
initPolling() {
const { lastFetchedAt } = $('.js-notes-wrapper')[0].dataset;
this.$store.commit('setLastFetchedAt', lastFetchedAt);
// FIXME: @fatihacet Implement real polling mechanism
setInterval(() => {
this.$store.dispatch('poll')
.then((res) => {
this.$store.commit('setLastFetchedAt', res.lastFetchedAt);
}) })
.catch(() => { .catch(() => {
Flash('Something went wrong while fetching latest comments.'); Flash('Something went wrong while fetching issue comments. Please try again.');
}); });
}, 15000); },
}, initPolling() {
bindEventHubListeners() { const { lastFetchedAt } = $('.js-notes-wrapper')[0].dataset;
eventHub.$on('toggleAward', (data) => { this.setLastFetchedAt(lastFetchedAt);
const { awardName, noteId } = data;
const endpoint = this.notesById[noteId].toggle_award_path;
this.$store.dispatch('toggleAward', { endpoint, awardName, noteId }) // FIXME: @fatihacet Implement real polling mechanism
.catch(() => { setInterval(() => {
Flash('Something went wrong on our end.'); this.poll()
}); .then((res) => {
}); this.setLastFetchedAt(res.lastFetchedAt);
})
.catch(() => {
Flash('Something went wrong while fetching latest comments.');
});
}, 15000);
},
bindEventHubListeners() {
eventHub.$on('toggleAward', (data) => {
const { awardName, noteId } = data;
const endpoint = this.notesById[noteId].toggle_award_path;
$(document).on('issuable:change', (e, isClosed) => { this.toggleAward({ endpoint, awardName, noteId })
eventHub.$emit('issueStateChanged', isClosed); .catch(() => {new Flash('Something went wrong on our end.')});
}); });
},
checkLocationHash() {
const hash = gl.utils.getLocationHash();
const $el = $(`#${hash}`);
if (hash && $el) { $(document).on('issuable:change', (e, isClosed) => {
this.$store.commit('setTargetNoteHash', hash); eventHub.$emit('issueStateChanged', isClosed);
this.$store.dispatch('scrollToNoteIfNeeded', $el); });
} },
checkLocationHash() {
const hash = gl.utils.getLocationHash();
const $el = $(`#${hash}`);
if (hash && $el) {
this.setTargetNoteHash(hash);
this.scrollToNoteIfNeeded($el);
}
},
}, },
}, mounted() {
mounted() { this.fetchNotes();
this.fetchNotes(); this.initPolling();
this.initPolling(); this.bindEventHubListeners();
this.bindEventHubListeners(); },
}, };
};
</script> </script>
<template> <template>
@ -121,9 +133,7 @@ export default {
<div <div
v-if="isLoading" v-if="isLoading"
class="loading"> class="loading">
<i <loading-icon />
class="fa fa-spinner fa-spin"
aria-hidden="true"></i>
</div> </div>
<ul <ul
v-if="!isLoading" v-if="!isLoading"
@ -131,9 +141,10 @@ export default {
class="notes main-notes-list timeline"> class="notes main-notes-list timeline">
<component <component
v-for="note in notes" v-for="note in notes"
:is="componentName(note)" :is="getComponentName(note)"
:note="componentData(note)" :note="getComponentData(note)"
:key="note.id" /> :key="note.id"
/>
</ul> </ul>
<issue-comment-form v-if="!isLoading" /> <issue-comment-form v-if="!isLoading" />
</div> </div>

View File

@ -1,17 +1,17 @@
<script> <script>
export default { export default {
props: { props: {
note: { note: {
type: Object, type: Object,
required: true, required: true,
},
}, },
}, data() {
data() { return {
return { currentUser: window.gl.currentUserData,
currentUser: window.gl.currentUserData, };
}; },
}, };
};
</script> </script>
<template> <template>
@ -21,7 +21,8 @@ export default {
<a :href="currentUser.path"> <a :href="currentUser.path">
<img <img
:src="currentUser.avatar_url" :src="currentUser.avatar_url"
class="avatar s40" /> class="avatar s40"
/>
</a> </a>
</div> </div>
<div <div

View File

@ -1,12 +1,12 @@
<script> <script>
export default { export default {
props: { props: {
note: { note: {
type: Object, type: Object,
required: true, required: true,
},
}, },
}, };
};
</script> </script>
<template> <template>

View File

@ -1,35 +1,35 @@
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import iconsMap from './issue_note_icons'; import iconsMap from './issue_note_icons';
import issueNoteHeader from './issue_note_header.vue'; import issueNoteHeader from './issue_note_header.vue';
export default { export default {
props: { props: {
note: { note: {
type: Object, type: Object,
required: true, required: true,
},
}, },
}, data() {
data() { return {
return { svg: iconsMap[this.note.system_note_icon_name],
svg: iconsMap[this.note.system_note_icon_name], };
};
},
components: {
issueNoteHeader,
},
computed: {
...mapGetters([
'targetNoteHash',
]),
noteAnchorId() {
return `note_${this.note.id}`;
}, },
isTargetNote() { components: {
return this.targetNoteHash === this.noteAnchorId; issueNoteHeader,
}, },
}, computed: {
}; ...mapGetters([
'targetNoteHash',
]),
noteAnchorId() {
return `note_${this.note.id}`;
},
isTargetNote() {
return this.targetNoteHash === this.noteAnchorId;
},
},
};
</script> </script>
<template> <template>

View File

@ -5,4 +5,4 @@ export const SYSTEM_NOTE = 'systemNote';
export const COMMENT = 'comment'; export const COMMENT = 'comment';
export const OPENED = 'opened'; export const OPENED = 'opened';
export const REOPENED = 'reopened'; export const REOPENED = 'reopened';
export const CLOSED = 'closed'; export const CLOSED = 'closed';

View File

@ -138,8 +138,7 @@ export const saveNote = ({ commit, dispatch }, noteData) => {
export const poll = ({ commit, state, getters }) => { export const poll = ({ commit, state, getters }) => {
const { notesPath } = $('.js-notes-wrapper')[0].dataset; const { notesPath } = $('.js-notes-wrapper')[0].dataset;
return service return service.poll(`${notesPath}?full_data=1`, state.lastFetchedAt)
.poll(`${notesPath}?full_data=1`, state.lastFetchedAt)
.then(res => res.json()) .then(res => res.json())
.then((res) => { .then((res) => {
if (res.notes.length) { if (res.notes.length) {
@ -188,8 +187,8 @@ export const toggleAward = ({ commit, getters, dispatch }, data) => {
}); });
if (amIAwarded) { if (amIAwarded) {
Object.assign(data, { awardName: counterAward }); data.awardName = counterAward;
Object.assign(data, { skipMutalityCheck: true }); data.skipMutalityCheck = true;
dispatch(types.TOGGLE_AWARD, data); dispatch(types.TOGGLE_AWARD, data);
} }