Commit 81002745 authored by Jacob Schatz's avatar Jacob Schatz

Merge branch 'issue-discussions-refactor' into 'master'

Issue discussions Vue refactor

See merge request !12069
parents 1e607251 92edb3ed
...@@ -2,17 +2,17 @@ ...@@ -2,17 +2,17 @@
import AccessorUtilities from './lib/utils/accessor'; import AccessorUtilities from './lib/utils/accessor';
window.Autosave = (function() { window.Autosave = (function() {
function Autosave(field, key) { function Autosave(field, key, resource) {
this.field = field; this.field = field;
this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe(); this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe();
this.resource = resource;
if (key.join != null) { if (key.join != null) {
key = key.join("/"); key = key.join('/');
} }
this.key = "autosave/" + key; this.key = 'autosave/' + key;
this.field.data("autosave", this); this.field.data('autosave', this);
this.restore(); this.restore();
this.field.on("input", (function(_this) { this.field.on('input', (function(_this) {
return function() { return function() {
return _this.save(); return _this.save();
}; };
...@@ -29,7 +29,17 @@ window.Autosave = (function() { ...@@ -29,7 +29,17 @@ window.Autosave = (function() {
if ((text != null ? text.length : void 0) > 0) { if ((text != null ? text.length : void 0) > 0) {
this.field.val(text); this.field.val(text);
} }
return this.field.trigger("input"); if (!this.resource && this.resource !== 'issue') {
this.field.trigger('input');
} else {
// v-model does not update with jQuery trigger
// https://github.com/vuejs/vue/issues/2804#issuecomment-216968137
const event = new Event('change', { bubbles: true, cancelable: false });
const field = this.field.get(0);
if (field) {
field.dispatchEvent(event);
}
}
}; };
Autosave.prototype.save = function() { Autosave.prototype.save = function() {
......
...@@ -109,6 +109,7 @@ class AwardsHandler { ...@@ -109,6 +109,7 @@ class AwardsHandler {
} }
$thumbsBtn.toggleClass('disabled', $userAuthored); $thumbsBtn.toggleClass('disabled', $userAuthored);
$thumbsBtn.prop('disabled', $userAuthored);
} }
// Create the emoji menu with the first category of emojis. // Create the emoji menu with the first category of emojis.
...@@ -234,14 +235,33 @@ class AwardsHandler { ...@@ -234,14 +235,33 @@ class AwardsHandler {
} }
addAward(votesBlock, awardUrl, emoji, checkMutuality, callback) { addAward(votesBlock, awardUrl, emoji, checkMutuality, callback) {
const isMainAwardsBlock = votesBlock.closest('.js-issue-note-awards').length;
if (gl.utils.isInIssuePage() && !isMainAwardsBlock) {
const id = votesBlock.attr('id').replace('note_', '');
$('.emoji-menu').removeClass('is-visible');
$('.js-add-award.is-active').removeClass('is-active');
const toggleAwardEvent = new CustomEvent('toggleAward', {
detail: {
awardName: emoji,
noteId: id,
},
});
document.querySelector('.js-vue-notes-event').dispatchEvent(toggleAwardEvent);
}
const normalizedEmoji = this.emoji.normalizeEmojiName(emoji); const normalizedEmoji = this.emoji.normalizeEmojiName(emoji);
const $emojiButton = this.findEmojiIcon(votesBlock, normalizedEmoji).parent(); const $emojiButton = this.findEmojiIcon(votesBlock, normalizedEmoji).parent();
this.postEmoji($emojiButton, awardUrl, normalizedEmoji, () => { this.postEmoji($emojiButton, awardUrl, normalizedEmoji, () => {
this.addAwardToEmojiBar(votesBlock, normalizedEmoji, checkMutuality); this.addAwardToEmojiBar(votesBlock, normalizedEmoji, checkMutuality);
return typeof callback === 'function' ? callback() : undefined; return typeof callback === 'function' ? callback() : undefined;
}); });
$('.emoji-menu').removeClass('is-visible'); $('.emoji-menu').removeClass('is-visible');
$('.js-add-award.is-active').removeClass('is-active'); return $('.js-add-award.is-active').removeClass('is-active');
} }
addAwardToEmojiBar(votesBlock, emoji, checkForMutuality) { addAwardToEmojiBar(votesBlock, emoji, checkForMutuality) {
...@@ -268,6 +288,14 @@ class AwardsHandler { ...@@ -268,6 +288,14 @@ class AwardsHandler {
} }
getVotesBlock() { getVotesBlock() {
if (gl.utils.isInIssuePage()) {
const $el = $('.js-add-award.is-active').closest('.note.timeline-entry');
if ($el.length) {
return $el;
}
}
const currentBlock = $('.js-awards-block.current'); const currentBlock = $('.js-awards-block.current');
let resultantVotesBlock = currentBlock; let resultantVotesBlock = currentBlock;
if (currentBlock.length === 0) { if (currentBlock.length === 0) {
......
...@@ -44,7 +44,10 @@ $(document).on('keydown.quick_submit', '.js-quick-submit', (e) => { ...@@ -44,7 +44,10 @@ $(document).on('keydown.quick_submit', '.js-quick-submit', (e) => {
if (!$submitButton.attr('disabled')) { if (!$submitButton.attr('disabled')) {
$submitButton.trigger('click', [e]); $submitButton.trigger('click', [e]);
$submitButton.disable();
if (!gl.utils.isInIssuePage()) {
$submitButton.disable();
}
} }
}); });
......
...@@ -99,7 +99,7 @@ import initChangesDropdown from './init_changes_dropdown'; ...@@ -99,7 +99,7 @@ import initChangesDropdown from './init_changes_dropdown';
path = page.split(':'); path = page.split(':');
shortcut_handler = null; shortcut_handler = null;
$('.js-gfm-input').each((i, el) => { $('.js-gfm-input:not(.js-vue-textarea)').each((i, el) => {
const gfm = new GfmAutoComplete(gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources); const gfm = new GfmAutoComplete(gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources);
const enableGFM = gl.utils.convertPermissionToBoolean(el.dataset.supportsAutocomplete); const enableGFM = gl.utils.convertPermissionToBoolean(el.dataset.supportsAutocomplete);
gfm.setup($(el), { gfm.setup($(el), {
...@@ -172,7 +172,6 @@ import initChangesDropdown from './init_changes_dropdown'; ...@@ -172,7 +172,6 @@ import initChangesDropdown from './init_changes_dropdown';
shortcut_handler = new ShortcutsIssuable(); shortcut_handler = new ShortcutsIssuable();
new ZenMode(); new ZenMode();
initIssuableSidebar(); initIssuableSidebar();
initNotes();
break; break;
case 'dashboard:milestones:index': case 'dashboard:milestones:index':
new ProjectSelect(); new ProjectSelect();
......
...@@ -128,7 +128,7 @@ window.DropzoneInput = (function() { ...@@ -128,7 +128,7 @@ window.DropzoneInput = (function() {
// removeAllFiles(true) stops uploading files (if any) // removeAllFiles(true) stops uploading files (if any)
// and remove them from dropzone files queue. // and remove them from dropzone files queue.
$cancelButton.on('click', (e) => { $cancelButton.on('click', (e) => {
const target = e.target.closest('form').querySelector('.div-dropzone'); const target = e.target.closest('.js-main-target-form').querySelector('.div-dropzone');
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
...@@ -140,7 +140,7 @@ window.DropzoneInput = (function() { ...@@ -140,7 +140,7 @@ window.DropzoneInput = (function() {
// and add that files to the dropzone files queue again. // and add that files to the dropzone files queue again.
// addFile() adds file to dropzone files queue and upload it. // addFile() adds file to dropzone files queue and upload it.
$retryLink.on('click', (e) => { $retryLink.on('click', (e) => {
const dropzoneInstance = Dropzone.forElement(e.target.closest('form').querySelector('.div-dropzone')); const dropzoneInstance = Dropzone.forElement(e.target.closest('.js-main-target-form').querySelector('.div-dropzone'));
const failedFiles = dropzoneInstance.files; const failedFiles = dropzoneInstance.files;
e.preventDefault(); e.preventDefault();
......
...@@ -42,7 +42,7 @@ class Issue { ...@@ -42,7 +42,7 @@ class Issue {
initIssueBtnEventListeners() { initIssueBtnEventListeners() {
const issueFailMessage = 'Unable to update this issue at this time.'; const issueFailMessage = 'Unable to update this issue at this time.';
return $(document).on('click', 'a.btn-close, a.btn-reopen', (e) => { return $(document).on('click', '.js-issuable-actions a.btn-close, .js-issuable-actions a.btn-reopen', (e) => {
var $button, shouldSubmit, url; var $button, shouldSubmit, url;
e.preventDefault(); e.preventDefault();
e.stopImmediatePropagation(); e.stopImmediatePropagation();
...@@ -66,12 +66,11 @@ class Issue { ...@@ -66,12 +66,11 @@ class Issue {
const projectIssuesCounter = $('.issue_counter'); const projectIssuesCounter = $('.issue_counter');
if ('id' in data) { if ('id' in data) {
$(document).trigger('issuable:change');
const isClosed = $button.hasClass('btn-close'); const isClosed = $button.hasClass('btn-close');
isClosedBadge.toggleClass('hidden', !isClosed); isClosedBadge.toggleClass('hidden', !isClosed);
isOpenBadge.toggleClass('hidden', isClosed); isOpenBadge.toggleClass('hidden', isClosed);
$(document).trigger('issuable:change', isClosed);
this.toggleCloseReopenButton(isClosed); this.toggleCloseReopenButton(isClosed);
let numProjectIssues = Number(projectIssuesCounter.text().replace(/[^\d]/, '')); let numProjectIssues = Number(projectIssuesCounter.text().replace(/[^\d]/, ''));
...@@ -121,7 +120,7 @@ class Issue { ...@@ -121,7 +120,7 @@ class Issue {
static submitNoteForm(form) { static submitNoteForm(form) {
var noteText; var noteText;
noteText = form.find("textarea.js-note-text").val(); noteText = form.find("textarea.js-note-text").val();
if (noteText.trim().length > 0) { if (noteText && noteText.trim().length > 0) {
return form.submit(); return form.submit();
} }
} }
......
...@@ -80,11 +80,11 @@ export default { ...@@ -80,11 +80,11 @@ export default {
type: Boolean, type: Boolean,
required: true, required: true,
}, },
markdownPreviewUrl: { markdownPreviewPath: {
type: String, type: String,
required: true, required: true,
}, },
markdownDocs: { markdownDocsPath: {
type: String, type: String,
required: true, required: true,
}, },
...@@ -96,7 +96,7 @@ export default { ...@@ -96,7 +96,7 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
projectsAutocompleteUrl: { projectsAutocompletePath: {
type: String, type: String,
required: true, required: true,
}, },
...@@ -242,11 +242,11 @@ export default { ...@@ -242,11 +242,11 @@ export default {
:can-move="canMove" :can-move="canMove"
:can-destroy="canDestroy" :can-destroy="canDestroy"
:issuable-templates="issuableTemplates" :issuable-templates="issuableTemplates"
:markdown-docs="markdownDocs" :markdown-docs-path="markdownDocsPath"
:markdown-preview-url="markdownPreviewUrl" :markdown-preview-path="markdownPreviewPath"
:project-path="projectPath" :project-path="projectPath"
:project-namespace="projectNamespace" :project-namespace="projectNamespace"
:projects-autocomplete-url="projectsAutocompleteUrl" :projects-autocomplete-path="projectsAutocompletePath"
/> />
<div v-else> <div v-else>
<title-component <title-component
......
...@@ -10,11 +10,11 @@ ...@@ -10,11 +10,11 @@
type: Object, type: Object,
required: true, required: true,
}, },
markdownPreviewUrl: { markdownPreviewPath: {
type: String, type: String,
required: true, required: true,
}, },
markdownDocs: { markdownDocsPath: {
type: String, type: String,
required: true, required: true,
}, },
...@@ -36,8 +36,8 @@ ...@@ -36,8 +36,8 @@
Description Description
</label> </label>
<markdown-field <markdown-field
:markdown-preview-url="markdownPreviewUrl" :markdown-preview-path="markdownPreviewPath"
:markdown-docs="markdownDocs"> :markdown-docs-path="markdownDocsPath">
<textarea <textarea
id="issue-description" id="issue-description"
class="note-textarea js-gfm-input js-autosize markdown-area" class="note-textarea js-gfm-input js-autosize markdown-area"
......
...@@ -10,7 +10,7 @@ ...@@ -10,7 +10,7 @@
type: Object, type: Object,
required: true, required: true,
}, },
projectsAutocompleteUrl: { projectsAutocompletePath: {
type: String, type: String,
required: true, required: true,
}, },
...@@ -20,7 +20,7 @@ ...@@ -20,7 +20,7 @@
$moveDropdown.select2({ $moveDropdown.select2({
ajax: { ajax: {
url: this.projectsAutocompleteUrl, url: this.projectsAutocompletePath,
quietMillis: 125, quietMillis: 125,
data(term, page, context) { data(term, page, context) {
return { return {
......
...@@ -26,11 +26,11 @@ ...@@ -26,11 +26,11 @@
required: false, required: false,
default: () => [], default: () => [],
}, },
markdownPreviewUrl: { markdownPreviewPath: {
type: String, type: String,
required: true, required: true,
}, },
markdownDocs: { markdownDocsPath: {
type: String, type: String,
required: true, required: true,
}, },
...@@ -42,7 +42,7 @@ ...@@ -42,7 +42,7 @@
type: String, type: String,
required: true, required: true,
}, },
projectsAutocompleteUrl: { projectsAutocompletePath: {
type: String, type: String,
required: true, required: true,
}, },
...@@ -89,14 +89,14 @@ ...@@ -89,14 +89,14 @@
</div> </div>
<description-field <description-field
:form-state="formState" :form-state="formState"
:markdown-preview-url="markdownPreviewUrl" :markdown-preview-path="markdownPreviewPath"
:markdown-docs="markdownDocs" /> :markdown-docs-path="markdownDocsPath" />
<confidential-checkbox <confidential-checkbox
:form-state="formState" /> :form-state="formState" />
<project-move <project-move
v-if="canMove" v-if="canMove"
:form-state="formState" :form-state="formState"
:projects-autocomplete-url="projectsAutocompleteUrl" /> :projects-autocomplete-path="projectsAutocompletePath" />
<edit-actions <edit-actions
:form-state="formState" :form-state="formState"
:can-destroy="canDestroy" /> :can-destroy="canDestroy" />
......
...@@ -37,11 +37,11 @@ document.addEventListener('DOMContentLoaded', () => { ...@@ -37,11 +37,11 @@ document.addEventListener('DOMContentLoaded', () => {
initialDescriptionText: this.initialDescriptionText, initialDescriptionText: this.initialDescriptionText,
issuableTemplates: this.issuableTemplates, issuableTemplates: this.issuableTemplates,
isConfidential: this.isConfidential, isConfidential: this.isConfidential,
markdownPreviewUrl: this.markdownPreviewUrl, markdownPreviewPath: this.markdownPreviewPath,
markdownDocs: this.markdownDocs, markdownDocsPath: this.markdownDocsPath,
projectPath: this.projectPath, projectPath: this.projectPath,
projectNamespace: this.projectNamespace, projectNamespace: this.projectNamespace,
projectsAutocompleteUrl: this.projectsAutocompleteUrl, projectsAutocompletePath: this.projectsAutocompletePath,
updatedAt: this.updatedAt, updatedAt: this.updatedAt,
updatedByName: this.updatedByName, updatedByName: this.updatedByName,
updatedByPath: this.updatedByPath, updatedByPath: this.updatedByPath,
......
...@@ -27,6 +27,13 @@ ...@@ -27,6 +27,13 @@
} }
}; };
w.gl.utils.isInIssuePage = () => {
const page = gl.utils.getPagePath(1);
const action = gl.utils.getPagePath(2);
return page === 'issues' && action === 'show';
};
w.gl.utils.ajaxGet = function(url) { w.gl.utils.ajaxGet = function(url) {
return $.ajax({ return $.ajax({
type: "GET", type: "GET",
...@@ -167,11 +174,12 @@ ...@@ -167,11 +174,12 @@
}; };
gl.utils.scrollToElement = function($el) { gl.utils.scrollToElement = function($el) {
var top = $el.offset().top; const top = $el.offset().top;
gl.mrTabsHeight = gl.mrTabsHeight || $('.merge-request-tabs').height(); const mrTabsHeight = $('.merge-request-tabs').height() || 0;
const headerHeight = $('.navbar-gitlab').height() || 0;
return $('body, html').animate({ return $('body, html').animate({
scrollTop: top - (gl.mrTabsHeight) scrollTop: top - mrTabsHeight - headerHeight,
}, 200); }, 200);
}; };
......
<script>
/* global Flash, Autosave */
import { mapActions, mapGetters } from 'vuex';
import _ from 'underscore';
import '../../autosave';
import TaskList from '../../task_list';
import * as constants from '../constants';
import eventHub from '../event_hub';
import confidentialIssue from '../../vue_shared/components/issue/confidential_issue_warning.vue';
import issueNoteSignedOutWidget from './issue_note_signed_out_widget.vue';
import markdownField from '../../vue_shared/components/markdown/field.vue';
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
export default {
name: 'issueCommentForm',
data() {
return {
note: '',
noteType: constants.COMMENT,
// Can't use mapGetters,
// this needs to be in the data object because it belongs to the state
issueState: this.$store.getters.getIssueData.state,
isSubmitting: false,
isSubmitButtonDisabled: true,
};
},
components: {
confidentialIssue,
issueNoteSignedOutWidget,
markdownField,
userAvatarLink,
},
watch: {
note(newNote) {
this.setIsSubmitButtonDisabled(newNote, this.isSubmitting);
},
isSubmitting(newValue) {
this.setIsSubmitButtonDisabled(this.note, newValue);
},
},
computed: {
...mapGetters([
'getCurrentUserLastNote',
'getUserData',
'getIssueData',
'getNotesData',
]),
isLoggedIn() {
return this.getUserData.id;
},
commentButtonTitle() {
return this.noteType === constants.COMMENT ? 'Comment' : 'Start discussion';
},
isIssueOpen() {
return this.issueState === constants.OPENED || this.issueState === constants.REOPENED;
},
issueActionButtonTitle() {
if (this.note.length) {
const actionText = this.isIssueOpen ? 'close' : 'reopen';
return this.noteType === constants.COMMENT ? `Comment & ${actionText} issue` : `Start discussion & ${actionText} issue`;
}
return this.isIssueOpen ? 'Close issue' : 'Reopen issue';
},
actionButtonClassNames() {
return {
'btn-reopen': !this.isIssueOpen,
'btn-close': this.isIssueOpen,
'js-note-target-close': this.isIssueOpen,
'js-note-target-reopen': !this.isIssueOpen,
};
},
markdownDocsPath() {
return this.getNotesData.markdownDocsPath;
},
quickActionsDocsPath() {
return this.getNotesData.quickActionsDocsPath;
},
markdownPreviewPath() {
return this.getIssueData.preview_note_path;
},
author() {
return this.getUserData;
},
canUpdateIssue() {
return this.getIssueData.current_user.can_update;
},
endpoint() {
return this.getIssueData.create_note_path;
},
isConfidentialIssue() {
return this.getIssueData.confidential;
},
},
methods: {
...mapActions([
'saveNote',
'removePlaceholderNotes',
]),
setIsSubmitButtonDisabled(note, isSubmitting) {
if (!_.isEmpty(note) && !isSubmitting) {
this.isSubmitButtonDisabled = false;
} else {
this.isSubmitButtonDisabled = true;
}
},
handleSave(withIssueAction) {
if (this.note.length) {
const noteData = {
endpoint: this.endpoint,
flashContainer: this.$el,
data: {
note: {
noteable_type: constants.NOTEABLE_TYPE,
noteable_id: this.getIssueData.id,
note: this.note,
},
},
};
if (this.noteType === constants.DISCUSSION) {
noteData.data.note.type = constants.DISCUSSION_NOTE;
}
this.isSubmitting = true;
this.note = ''; // Empty textarea while being requested. Repopulate in catch
this.saveNote(noteData)
.then((res) => {
this.isSubmitting = false;
if (res.errors) {
if (res.errors.commands_only) {
this.discard();
} else {
Flash(
'Something went wrong while adding your comment. Please try again.',
'alert',
$(this.$refs.commentForm),
);
}
} else {
this.discard();
}
if (withIssueAction) {
this.toggleIssueState();
}
})
.catch(() => {
this.isSubmitting = false;
this.discard(false);
const msg = 'Your comment could not be submitted! Please check your network connection and try again.';
Flash(msg, 'alert', $(this.$el));
this.note = noteData.data.note.note; // Restore textarea content.
this.removePlaceholderNotes();
});
} else {
this.toggleIssueState();
}
},
toggleIssueState() {
this.issueState = this.isIssueOpen ? constants.CLOSED : constants.REOPENED;
// This is out of scope for the Notes Vue component.
// It was the shortest path to update the issue state and relevant places.
const btnClass = this.isIssueOpen ? 'btn-reopen' : 'btn-close';
$(`.js-btn-issue-action.${btnClass}:visible`).trigger('click');
},
discard(shouldClear = true) {
// `blur` is needed to clear slash commands autocomplete cache if event fired.
// `focus` is needed to remain cursor in the textarea.
this.$refs.textarea.blur();
this.$refs.textarea.focus();
if (shouldClear) {
this.note = '';
}
// reset autostave
this.autosave.reset();
},
setNoteType(type) {
this.noteType = type;
},
editCurrentUserLastNote() {
if (this.note === '') {
const lastNote = this.getCurrentUserLastNote;
if (lastNote) {
eventHub.$emit('enterEditMode', {
noteId: lastNote.id,
});
}
}
},
initAutoSave() {
if (this.isLoggedIn) {
this.autosave = new Autosave($(this.$refs.textarea), ['Note', 'Issue', this.getIssueData.id], 'issue');
}
},
initTaskList() {
return new TaskList({
dataType: 'note',
fieldName: 'note',
selector: '.notes',
});
},
},
mounted() {
// jQuery is needed here because it is a custom event being dispatched with jQuery.
$(document).on('issuable:change', (e, isClosed) => {
this.issueState = isClosed ? constants.CLOSED : constants.REOPENED;
});
this.initAutoSave();
this.initTaskList();
},
};
</script>
<template>
<div>
<issue-note-signed-out-widget v-if="!isLoggedIn" />
<ul
v-else
class="notes notes-form timeline">
<li class="timeline-entry">
<div class="timeline-entry-inner">
<div class="flash-container error-alert timeline-content"></div>
<div class="timeline-icon hidden-xs hidden-sm">
<user-avatar-link
v-if="author"
:link-href="author.path"
:img-src="author.avatar_url"
:img-alt="author.name"
:img-size="40"
/>
</div>
<div class="timeline-content timeline-content-form">
<form
ref="commentForm"
class="new-note js-quick-submit common-note-form gfm-form js-main-target-form">
<confidentialIssue v-if="isConfidentialIssue" />
<div class="error-alert"></div>
<markdown-field
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
:quick-actions-docs-path="quickActionsDocsPath"
:add-spacing-classes="false"
:is-confidential-issue="isConfidentialIssue">
<textarea
id="note-body"
name="note[note]"
class="note-textarea js-vue-comment-form js-gfm-input js-autosize markdown-area js-vue-textarea"
data-supports-quick-actions="true"
aria-label="Description"
v-model="note"
ref="textarea"
slot="textarea"
placeholder="Write a comment or drag your files here..."
@keydown.up="editCurrentUserLastNote()"
@keydown.meta.enter="handleSave()">
</textarea>
</markdown-field>
<div class="note-form-actions">
<div class="pull-left btn-group append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown">
<button
@click.prevent="handleSave()"
:disabled="isSubmitButtonDisabled"
class="btn btn-create comment-btn js-comment-button js-comment-submit-button"
type="submit">
{{commentButtonTitle}}
</button>
<button
:disabled="isSubmitButtonDisabled"
name="button"
type="button"
class="btn comment-btn note-type-toggle js-note-new-discussion dropdown-toggle"
data-toggle="dropdown"
aria-label="Open comment type dropdown">
<i
aria-hidden="true"
class="fa fa-caret-down toggle-icon">
</i>
</button>
<ul class="note-type-dropdown dropdown-open-top dropdown-menu">
<li :class="{ 'droplab-item-selected': noteType === 'comment' }">
<button
type="button"
class="btn btn-transparent"
@click.prevent="setNoteType('comment')">
<i
aria-hidden="true"
class="fa fa-check icon">
</i>
<div class="description">
<strong>Comment</strong>
<p>
Add a general comment to this issue.
</p>
</div>
</button>
</li>
<li class="divider droplab-item-ignore"></li>
<li :class="{ 'droplab-item-selected': noteType === 'discussion' }">
<button
type="button"
class="btn btn-transparent"
@click.prevent="setNoteType('discussion')">
<i
aria-hidden="true"
class="fa fa-check icon">
</i>
<div class="description">
<strong>Start discussion</strong>
<p>
Discuss a specific suggestion or question.
</p>
</div>
</button>
</li>
</ul>
</div>
<button
type="button"
@click="handleSave(true)"
v-if="canUpdateIssue"
:class="actionButtonClassNames"
class="btn btn-comment btn-comment-and-close">
{{issueActionButtonTitle}}
</button>
<button
type="button"
v-if="note.length"
@click="discard"
class="btn btn-cancel js-note-discard">
Discard draft
</button>
</div>
</form>
</div>
</div>
</li>
</ul>
</div>
</template>
<script>
/* global Flash */
import { mapActions, mapGetters } from 'vuex';
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 autosave from '../mixins/autosave';
export default {
props: {
note: {
type: Object,
required: true,
},
},
data() {
return {
isReplying: false,
};
},
components: {
issueNote,
userAvatarLink,
issueNoteHeader,
issueNoteActions,
issueNoteSignedOutWidget,
issueNoteEditedText,
issueNoteForm,
placeholderNote,
placeholderSystemNote,
},
mixins: [
autosave,
],
computed: {
...mapGetters([
'getIssueData',
]),
discussion() {
return this.note.notes[0];
},
author() {
return this.discussion.author;
},
canReply() {
return this.getIssueData.current_user.can_create_note;
},
newNotePath() {
return this.getIssueData.create_note_path;
},
lastUpdatedBy() {
const { notes } = this.note;
if (notes.length > 1) {
return notes[notes.length - 1].author;
}
return null;
},
lastUpdatedAt() {
const { notes } = this.note;
if (notes.length > 1) {
return notes[notes.length - 1].created_at;
}
return null;
},
},
methods: {
...mapActions([
'saveNote',
'toggleDiscussion',
'removePlaceholderNotes',
]),
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;
},
toggleDiscussionHandler() {
this.toggleDiscussion({ discussionId: this.note.id });
},
showReplyForm() {
this.isReplying = true;
},
cancelReplyForm(shouldConfirm) {
if (shouldConfirm && this.$refs.noteForm.isDirty) {
// eslint-disable-next-line no-alert
if (!confirm('Are you sure you want to cancel creating this comment?')) {
return;
}
}
this.resetAutoSave();
this.isReplying = false;
},
saveReply(noteText, form, callback) {
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: noteText },
},
};
this.isReplying = false;
this.saveNote(replyData)
.then(() => {
this.resetAutoSave();
callback();
})
.catch((err) => {
this.removePlaceholderNotes();
this.isReplying = true;
this.$nextTick(() => {
const msg = 'Your comment could not be submitted! Please check your network connection and try again.';
Flash(msg, 'alert', $(this.$el));
this.$refs.noteForm.note = noteText;
callback(err);
});
});
},
},
mounted() {
if (this.isReplying) {
this.initAutoSave();
}
},
updated() {
if (this.isReplying) {
if (!this.autosave) {
this.initAutoSave();
} else {
this.setAutoSave();
}
}
},
};
</script>
<template>
<li class="note note-discussion timeline-entry">
<div class="timeline-entry-inner">
<div class="timeline-icon">
<user-avatar-link
:link-href="author.path"
:img-src="author.avatar_url"
:img-alt="author.name"
:img-size="40"
/>
</div>
<div class="timeline-content">
<div class="discussion">
<div class="discussion-header">
<issue-note-header
:author="author"
:created-at="discussion.created_at"
:note-id="discussion.id"
:include-toggle="true"
@toggleHandler="toggleDiscussionHandler"
action-text="started a discussion"
class="discussion"
/>
<issue-note-edited-text
v-if="lastUpdatedAt"
:edited-at="lastUpdatedAt"
:edited-by="lastUpdatedBy"
action-text="Last updated"
class-name="discussion-headline-light js-discussion-headline"
/>
</div>
</div>
<div
v-if="note.expanded"
class="discussion-body">
<div class="panel panel-default">
<div class="discussion-notes">
<ul class="notes">
<component
v-for="note in note.notes"
:is="componentName(note)"
:note="componentData(note)"
:key="note.id"
/>
</ul>
<div
:class="{ 'is-replying': isReplying }"
class="discussion-reply-holder">
<button
v-if="canReply && !isReplying"
@click="showReplyForm"
type="button"
class="js-vue-discussion-reply btn btn-text-field"
title="Add a reply">Reply...</button>
<issue-note-form
v-if="isReplying"
save-button-title="Comment"
:discussion="note"
:is-editing="false"
@handleFormUpdate="saveReply"
@cancelFormEdition="cancelReplyForm"
ref="noteForm"
/>
<issue-note-signed-out-widget v-if="!canReply" />
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</li>
</template>
<script>
/* global Flash */
import { mapGetters, mapActions } from 'vuex';
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 issueNoteBody from './issue_note_body.vue';
import eventHub from '../event_hub';
export default {
props: {
note: {
type: Object,
required: true,
},
},
data() {
return {
isEditing: false,
isDeleting: false,
isRequesting: false,
};
},
components: {
userAvatarLink,
issueNoteHeader,
issueNoteActions,
issueNoteBody,
},
computed: {
...mapGetters([
'targetNoteHash',
'getUserData',
]),
author() {
return this.note.author;
},
classNameBindings() {
return {
'is-editing': this.isEditing && !this.isRequesting,
'is-requesting being-posted': this.isRequesting,
'disabled-content': this.isDeleting,
target: this.targetNoteHash === this.noteAnchorId,
};
},
canReportAsAbuse() {
return this.note.report_abuse_path && this.author.id !== this.getUserData.id;
},
noteAnchorId() {
return `note_${this.note.id}`;
},
},
methods: {
...mapActions([
'deleteNote',
'updateNote',
'scrollToNoteIfNeeded',
]),
editHandler() {
this.isEditing = true;
},
deleteHandler() {
// eslint-disable-next-line no-alert
if (confirm('Are you sure you want to delete this list?')) {
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(noteText, parentElement, callback) {
const data = {
endpoint: this.note.path,
note: {
target_type: 'issue',
target_id: this.note.noteable_id,
note: { note: noteText },
},
};
this.isRequesting = true;
this.oldContent = this.note.note_html;
this.note.note_html = noteText;
this.updateNote(data)
.then(() => {
this.isEditing = false;
this.isRequesting = false;
$(this.$refs.noteBody.$el).renderGFM();
this.$refs.noteBody.resetAutoSave();
callback();
})
.catch(() => {
this.isRequesting = false;
this.isEditing = true;
this.$nextTick(() => {
const msg = 'Something went wrong while editing your comment. Please try again.';
Flash(msg, 'alert', $(this.$el));
this.recoverNoteContent(noteText);
callback();
});
});
},
formCancelHandler(shouldConfirm, isDirty) {
if (shouldConfirm && isDirty) {
// eslint-disable-next-line no-alert
if (!confirm('Are you sure you want to cancel editing this comment?')) return;
}
this.$refs.noteBody.resetAutoSave();
if (this.oldContent) {
this.note.note_html = this.oldContent;
this.oldContent = null;
}
this.isEditing = false;
},
recoverNoteContent(noteText) {
// we need to do this to prevent noteForm inconsistent content warning
// this is something we intentionally do so we need to recover the content
this.note.note = noteText;
this.$refs.noteBody.$refs.noteForm.note = noteText; // TODO: This could be better
},
},
created() {
eventHub.$on('enterEditMode', ({ noteId }) => {
if (noteId === this.note.id) {
this.isEditing = true;
this.scrollToNoteIfNeeded($(this.$el));
}
});
},
};
</script>
<template>
<li
class="note timeline-entry"
:id="noteAnchorId"
:class="classNameBindings"
:data-award-url="note.toggle_award_path">
<div class="timeline-entry-inner">
<div class="timeline-icon">
<user-avatar-link
:link-href="author.path"
:img-src="author.avatar_url"
:img-alt="author.name"
:img-size="40"
/>
</div>
<div class="timeline-content">
<div class="note-header">
<issue-note-header
:author="author"
:created-at="note.created_at"
:note-id="note.id"
action-text="commented"
/>
<issue-note-actions
:author-id="author.id"
:note-id="note.id"
:access-level="note.human_access"
:can-edit="note.current_user.can_edit"
:can-delete="note.current_user.can_edit"
:can-report-as-abuse="canReportAsAbuse"
:report-abuse-path="note.report_abuse_path"
@handleEdit="editHandler"
@handleDelete="deleteHandler"
/>
</div>
<issue-note-body
:note="note"
:can-edit="note.current_user.can_edit"
:is-editing="isEditing"
@handleFormUpdate="formUpdateHandler"
@cancelFormEdition="formCancelHandler"
ref="noteBody"
/>
</div>
</div>
</li>
</template>
<script>
import { mapGetters } from 'vuex';
import emojiSmiling from 'icons/_emoji_slightly_smiling_face.svg';
import emojiSmile from 'icons/_emoji_smile.svg';
import emojiSmiley from 'icons/_emoji_smiley.svg';
import editSvg from 'icons/_icon_pencil.svg';
import ellipsisSvg from 'icons/_ellipsis_v.svg';
import loadingIcon from '../../vue_shared/components/loading_icon.vue';
import tooltip from '../../vue_shared/directives/tooltip';
export default {
name: 'issueNoteActions',
props: {
authorId: {
type: Number,
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,
},
},
directives: {
tooltip,
},
components: {
loadingIcon,
},
computed: {
...mapGetters([
'getUserDataByProp',
]),
shouldShowActionsDropdown() {
return this.currentUserId && (this.canEdit || this.canReportAsAbuse);
},
canAddAwardEmoji() {
return this.currentUserId;
},
isAuthoredByCurrentUser() {
return this.authorId === this.currentUserId;
},
currentUserId() {
return this.getUserDataByProp('id');
},
},
methods: {
onEdit() {
this.$emit('handleEdit');
},
onDelete() {
this.$emit('handleDelete');
},
},
created() {
this.emojiSmiling = emojiSmiling;
this.emojiSmile = emojiSmile;
this.emojiSmiley = emojiSmiley;
this.editSvg = editSvg;
this.ellipsisSvg = ellipsisSvg;
},
};
</script>
<template>
<div class="note-actions">
<span
v-if="accessLevel"
class="note-role">{{accessLevel}}</span>
<div
v-if="canAddAwardEmoji"
class="note-actions-item">
<a
v-tooltip
:class="{ 'js-user-authored': isAuthoredByCurrentUser }"
class="note-action-button note-emoji-button js-add-award js-note-emoji"
data-position="right"
data-placement="bottom"
data-container="body"
href="#"
title="Add reaction">
<loading-icon :inline="true" />
<span
v-html="emojiSmiling"
class="link-highlight award-control-icon-neutral">
</span>
<span
v-html="emojiSmiley"
class="link-highlight award-control-icon-positive">
</span>
<span
v-html="emojiSmile"
class="link-highlight award-control-icon-super-positive">
</span>
</a>
</div>
<div
v-if="canEdit"
class="note-actions-item">
<button
@click="onEdit"
v-tooltip
type="button"
title="Edit comment"
class="note-action-button js-note-edit btn btn-transparent"
data-container="body"
data-placement="bottom">
<span
v-html="editSvg"
class="link-highlight"></span>
</button>
</div>
<div
v-if="shouldShowActionsDropdown"
class="dropdown more-actions note-actions-item">
<button
v-tooltip
type="button"
title="More actions"
class="note-action-button more-actions-toggle btn btn-transparent"
data-toggle="dropdown"
data-container="body"
data-placement="bottom">
<span
class="icon"
v-html="ellipsisSvg"></span>
</button>
<ul class="dropdown-menu more-actions-dropdown dropdown-open-left">
<li v-if="canReportAsAbuse">
<a :href="reportAbusePath">
Report as abuse
</a>
</li>
<li v-if="canEdit">
<button
@click.prevent="onDelete"
class="btn btn-transparent js-note-delete js-note-delete"
type="button">
<span class="text-danger">
Delete comment
</span>
</button>
</li>
</ul>
</div>
</div>
</template>
<script>
export default {
name: 'issueNoteAttachment',
props: {
attachment: {
type: Object,
required: true,
},
},
};
</script>
<template>
<div class="note-attachment">
<a
v-if="attachment.image"
:href="attachment.url"
target="_blank"
rel="noopener noreferrer">
<img
:src="attachment.url"
class="note-image-attach" />
</a>
<div class="attachment">
<a
v-if="attachment.url"
:href="attachment.url"
target="_blank"
rel="noopener noreferrer">
<i
class="fa fa-paperclip"
aria-hidden="true"></i>
{{attachment.filename}}
</a>
</div>
</div>
</template>
<script>
/* global Flash */
import { mapActions, mapGetters } from 'vuex';
import emojiSmiling from 'icons/_emoji_slightly_smiling_face.svg';
import emojiSmile from 'icons/_emoji_smile.svg';
import emojiSmiley from 'icons/_emoji_smiley.svg';
import { glEmojiTag } from '../../emoji';
import tooltip from '../../vue_shared/directives/tooltip';
export default {
props: {
awards: {
type: Array,
required: true,
},
toggleAwardPath: {
type: String,
required: true,
},
noteAuthorId: {
type: Number,
required: true,
},
noteId: {
type: Number,
required: true,
},
},
directives: {
tooltip,
},
computed: {
...mapGetters([
'getUserData',
]),
// `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 = this.awards.reduce((acc, award) => {
if (Object.prototype.hasOwnProperty.call(acc, award.name)) {
acc[award.name].push(award);
} else {
Object.assign(acc, { [award.name]: [award] });
}
return acc;
}, {});
const orderedAwards = {};
const { thumbsdown, thumbsup } = awards;
// Always show thumbsup and thumbsdown first
if (thumbsup) {
orderedAwards.thumbsup = thumbsup;
delete awards.thumbsup;
}
if (thumbsdown) {
orderedAwards.thumbsdown = thumbsdown;
delete awards.thumbsdown;
}
return Object.assign({}, orderedAwards, awards);
},
isAuthoredByMe() {
return this.noteAuthorId === this.getUserData.id;
},
isLoggedIn() {
return this.getUserData.id;
},
},
methods: {
...mapActions([
'toggleAwardRequest',
]),
getAwardHTML(name) {
return glEmojiTag(name);
},
getAwardClassBindings(awardList, awardName) {
return {
active: this.hasReactionByCurrentUser(awardList),
disabled: !this.canInteractWithEmoji(awardList, awardName),
};
},
canInteractWithEmoji(awardList, awardName) {
let isAllowed = true;
const restrictedEmojis = ['thumbsup', 'thumbsdown'];
// Users can not add :+1: and :-1: to their own notes
if (this.getUserData.id === this.noteAuthorId && restrictedEmojis.indexOf(awardName) > -1) {
isAllowed = false;
}
return this.getUserData.id && isAllowed;
},
hasReactionByCurrentUser(awardList) {
return awardList.filter(award => award.user.id === this.getUserData.id).length;
},
awardTitle(awardsList) {
const hasReactionByCurrentUser = this.hasReactionByCurrentUser(awardsList);
const TOOLTIP_NAME_COUNT = hasReactionByCurrentUser ? 9 : 10;
let awardList = awardsList;
// Filter myself from list if I am awarded.
if (hasReactionByCurrentUser) {
awardList = awardList.filter(award => award.user.id !== this.getUserData.id);
}
// 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 (hasReactionByCurrentUser) {
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) {
if (!this.isLoggedIn) {
return;
}
let parsedName;
// 100 and 1234 emoji are a number. Callback for v-for click sends it as a string
switch (awardName) {
case '100':
parsedName = 100;
break;
case '1234':
parsedName = 1234;
break;
default:
parsedName = awardName;
break;
}
const data = {
endpoint: this.toggleAwardPath,
noteId: this.noteId,
awardName: parsedName,
};
this.toggleAwardRequest(data)
.catch(() => Flash('Something went wrong on our end.'));
},
},
created() {
this.emojiSmiling = emojiSmiling;
this.emojiSmile = emojiSmile;
this.emojiSmiley = emojiSmiley;
},
};
</script>
<template>
<div class="note-awards">
<div class="awards js-awards-block">
<button
v-tooltip
v-for="(awardList, awardName, index) in groupedAwards"
:key="index"
:class="getAwardClassBindings(awardList, awardName)"
:title="awardTitle(awardList)"
@click="handleAward(awardName)"
class="btn award-control"
data-placement="bottom"
type="button">
<span v-html="getAwardHTML(awardName)"></span>
<span class="award-control-text js-counter">
{{awardList.length}}
</span>
</button>
<div
v-if="isLoggedIn"
class="award-menu-holder">
<button
v-tooltip
:class="{ 'js-user-authored': isAuthoredByMe }"
class="award-control btn js-add-award"
title="Add reaction"
aria-label="Add reaction"
data-placement="bottom"
type="button">
<span
v-html="emojiSmiling"
class="award-control-icon award-control-icon-neutral">
</span>
<span
v-html="emojiSmiley"
class="award-control-icon award-control-icon-positive">
</span>
<span
v-html="emojiSmile"
class="award-control-icon award-control-icon-super-positive">
</span>
<i
aria-hidden="true"
class="fa fa-spinner fa-spin award-control-icon award-control-icon-loading"></i>
</button>
</div>
</div>
</div>
</template>
<script>
import issueNoteEditedText from './issue_note_edited_text.vue';
import issueNoteAwardsList from './issue_note_awards_list.vue';
import issueNoteAttachment from './issue_note_attachment.vue';
import issueNoteForm from './issue_note_form.vue';
import TaskList from '../../task_list';
import autosave from '../mixins/autosave';
export default {
props: {
note: {
type: Object,
required: true,
},
canEdit: {
type: Boolean,
required: true,
},
isEditing: {
type: Boolean,
required: false,
default: false,
},
},
mixins: [
autosave,
],
components: {
issueNoteEditedText,
issueNoteAwardsList,
issueNoteAttachment,
issueNoteForm,
},
computed: {
noteBody() {
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(note, parentElement, callback) {
this.$emit('handleFormUpdate', note, parentElement, callback);
},
formCancelHandler(shouldConfirm, isDirty) {
this.$emit('cancelFormEdition', shouldConfirm, isDirty);
},
},
mounted() {
this.renderGFM();
this.initTaskList();
if (this.isEditing) {
this.initAutoSave();
}
},
updated() {
this.initTaskList();
this.renderGFM();
if (this.isEditing) {
if (!this.autosave) {
this.initAutoSave();
} else {
this.setAutoSave();
}
}
},
};
</script>
<template>
<div
:class="{ 'js-task-list-container': canEdit }"
ref="note-body"
class="note-body">
<div
v-html="note.note_html"
class="note-text md"></div>
<issue-note-form
v-if="isEditing"
ref="noteForm"
@handleFormUpdate="handleFormUpdate"
@cancelFormEdition="formCancelHandler"
:is-editing="isEditing"
:note-body="noteBody"
:note-id="note.id"
/>
<textarea
v-if="canEdit"
v-model="note.note"
:data-update-url="note.path"
class="hidden js-task-list-field"></textarea>
<issue-note-edited-text
v-if="note.last_edited_at"
:edited-at="note.last_edited_at"
:edited-by="note.last_edited_by"
action-text="Edited"
/>
<issue-note-awards-list
v-if="note.award_emoji.length"
:note-id="note.id"
:note-author-id="note.author.id"
:awards="note.award_emoji"
:toggle-award-path="note.toggle_award_path"
/>
<issue-note-attachment
v-if="note.attachment"
:attachment="note.attachment"
/>
</div>
</template>
<script>
import timeAgoTooltip from '../../vue_shared/components/time_ago_tooltip.vue';
export default {
name: 'editedNoteText',
props: {
actionText: {
type: String,
required: true,
},
editedAt: {
type: String,
required: true,
},
editedBy: {
type: Object,
required: false,
},
className: {
type: String,
required: false,
default: 'edited-text',
},
},
components: {
timeAgoTooltip,
},
};
</script>
<template>
<div :class="className">
{{actionText}}
<time-ago-tooltip
:time="editedAt"
tooltip-placement="bottom"
/>
<template v-if="editedBy">
by
<a
:href="editedBy.path"
class="js-vue-author author_link">
{{editedBy.name}}
</a>
</template>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import eventHub from '../event_hub';
import confidentialIssue from '../../vue_shared/components/issue/confidential_issue_warning.vue';
import markdownField from '../../vue_shared/components/markdown/field.vue';
export default {
name: 'issueNoteForm',
props: {
noteBody: {
type: String,
required: false,
default: '',
},
noteId: {
type: Number,
required: false,
},
saveButtonTitle: {
type: String,
required: false,
default: 'Save comment',
},
discussion: {
type: Object,
required: false,
default: () => ({}),
},
isEditing: {
type: Boolean,
required: true,
},
},
data() {
return {
note: this.noteBody,
conflictWhileEditing: false,
isSubmitting: false,
};
},
components: {
confidentialIssue,
markdownField,
},
computed: {
...mapGetters([
'getDiscussionLastNote',
'getIssueDataByProp',
'getNotesDataByProp',
'getUserDataByProp',
]),
noteHash() {
return `#note_${this.noteId}`;
},
markdownPreviewPath() {
return this.getIssueDataByProp('preview_note_path');
},
markdownDocsPath() {
return this.getNotesDataByProp('markdownDocsPath');
},
quickActionsDocsPath() {
return !this.isEditing ? this.getNotesDataByProp('quickActionsDocsPath') : undefined;
},
currentUserId() {
return this.getUserDataByProp('id');
},
isDisabled() {
return !this.note.length || this.isSubmitting;
},
isConfidentialIssue() {
return this.getIssueDataByProp('confidential');
},
},
methods: {
handleUpdate() {
this.isSubmitting = true;
this.$emit('handleFormUpdate', this.note, this.$refs.editNoteForm, () => {
this.isSubmitting = false;
});
},
editMyLastNote() {
if (this.note === '') {
const lastNoteInDiscussion = this.getDiscussionLastNote(this.discussion);
if (lastNoteInDiscussion) {
eventHub.$emit('enterEditMode', {
noteId: lastNoteInDiscussion.id,
});
}
}
},
cancelHandler(shouldConfirm = false) {
// Sends information about confirm message and if the textarea has changed
this.$emit('cancelFormEdition', shouldConfirm, this.noteBody !== this.note);
},
},
mounted() {
this.$refs.textarea.focus();
},
watch: {
noteBody() {
if (this.note === this.noteBody) {
this.note = this.noteBody;
} else {
this.conflictWhileEditing = true;
}
},
},
};
</script>
<template>
<div ref="editNoteForm" class="note-edit-form current-note-edit-form">
<div
v-if="conflictWhileEditing"
class="js-conflict-edit-warning alert alert-danger">
This comment has changed since you started editing, please review the
<a
:href="noteHash"
target="_blank"
rel="noopener noreferrer">updated comment</a>
to ensure information is not lost.
</div>
<div class="flash-container timeline-content"></div>
<form
class="edit-note common-note-form js-quick-submit gfm-form">
<confidentialIssue v-if="isConfidentialIssue" />
<markdown-field
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
:quick-actions-docs-path="quickActionsDocsPath"
:add-spacing-classes="false">
<textarea
id="note_note"
name="note[note]"
class="note-textarea js-gfm-input js-autosize markdown-area js-vue-issue-note-form js-vue-textarea"
:data-supports-quick-actions="!isEditing"
aria-label="Description"
v-model="note"
ref="textarea"
slot="textarea"
placeholder="Write a comment or drag your files here..."
@keydown.meta.enter="handleUpdate()"
@keydown.up="editMyLastNote()"
@keydown.esc="cancelHandler(true)">
</textarea>
</markdown-field>
<div class="note-form-actions clearfix">
<button
type="button"
@click="handleUpdate()"
:disabled="isDisabled"
class="js-vue-issue-save btn btn-save">
{{saveButtonTitle}}
</button>
<button
@click="cancelHandler()"
class="btn btn-cancel note-edit-cancel"
type="button">
Cancel
</button>
</div>
</form>
</div>
</template>
<script>
import { mapActions } from 'vuex';
import timeAgoTooltip from '../../vue_shared/components/time_ago_tooltip.vue';
export default {
props: {
author: {
type: Object,
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,
},
},
data() {
return {
isExpanded: true,
};
},
components: {
timeAgoTooltip,
},
computed: {
toggleChevronClass() {
return this.isExpanded ? 'fa-chevron-up' : 'fa-chevron-down';
},
noteTimestampLink() {
return `#note_${this.noteId}`;
},
},
methods: {
...mapActions([
'setTargetNoteHash',
]),
handleToggle() {
this.isExpanded = !this.isExpanded;
this.$emit('toggleHandler');
},
updateTargetNoteHash() {
this.setTargetNoteHash(this.noteTimestampLink);
},
},
};
</script>
<template>
<div class="note-header-info">
<a :href="author.path">
<span class="note-header-author-name">
{{author.name}}
</span>
<span class="note-headline-light">
@{{author.username}}
</span>
</a>
<span class="note-headline-light">
<span class="note-headline-meta">
<template v-if="actionText">
{{actionText}}
</template>
<span
v-if="actionTextHtml"
v-html="actionTextHtml"
class="system-note-message">
</span>
<a
:href="noteTimestampLink"
@click="updateTargetNoteHash"
class="note-timestamp">
<time-ago-tooltip
:time="createdAt"
tooltip-placement="bottom"
/>
</a>
<i
class="fa fa-spinner fa-spin editing-spinner"
aria-label="Comment is being updated"
aria-hidden="true">
</i>
</span>
</span>
<div
v-if="includeToggle"
class="discussion-actions">
<button
@click="handleToggle"
class="note-action-button discussion-toggle-button js-vue-toggle-button"
type="button">
<i
:class="toggleChevronClass"
class="fa"
aria-hidden="true">
</i>
Toggle discussion
</button>
</div>
</div>
</template>
import iconArrowCircle from 'icons/_icon_arrow_circle_o_right.svg';
import iconCheck from 'icons/_icon_check_square_o.svg';
import iconClock from 'icons/_icon_clock_o.svg';
import iconCodeFork from 'icons/_icon_code_fork.svg';
import iconComment from 'icons/_icon_comment_o.svg';
import iconCommit from 'icons/_icon_commit.svg';
import iconEdit from 'icons/_icon_edit.svg';
import iconEye from 'icons/_icon_eye.svg';
import iconEyeSlash from 'icons/_icon_eye_slash.svg';
import iconMerge from 'icons/_icon_merge.svg';
import iconMerged from 'icons/_icon_merged.svg';
import iconRandom from 'icons/_icon_random.svg';
import iconClosed from 'icons/_icon_status_closed.svg';
import iconStatusOpen from 'icons/_icon_status_open.svg';
import iconStopwatch from 'icons/_icon_stopwatch.svg';
import iconTags from 'icons/_icon_tags.svg';
import iconUser from 'icons/_icon_user.svg';
export default {
icon_arrow_circle_o_right: iconArrowCircle,
icon_check_square_o: iconCheck,
icon_clock_o: iconClock,
icon_code_fork: iconCodeFork,
icon_comment_o: iconComment,
icon_commit: iconCommit,
icon_edit: iconEdit,
icon_eye: iconEye,
icon_eye_slash: iconEyeSlash,
icon_merge: iconMerge,
icon_merged: iconMerged,
icon_random: iconRandom,
icon_status_closed: iconClosed,
icon_status_open: iconStatusOpen,
icon_stopwatch: iconStopwatch,
icon_tags: iconTags,
icon_user: iconUser,
};
<script>
import { mapGetters } from 'vuex';
export default {
name: 'singInLinksNotes',
computed: {
...mapGetters([
'getNotesDataByProp',
]),
registerLink() {
return this.getNotesDataByProp('registerPath');
},
signInLink() {
return this.getNotesDataByProp('newSessionPath');
},
},
};
</script>
<template>
<div class="disabled-comment text-center">
Please
<a :href="registerLink">register</a>
or
<a :href="signInLink">sign in</a>
to reply
</div>
</template>
<script>
/* global Flash */
import { mapGetters, mapActions } from 'vuex';
import store from '../stores/';
import * as constants from '../constants';
import issueNote from './issue_note.vue';
import issueDiscussion from './issue_discussion.vue';
import issueSystemNote from './issue_system_note.vue';
import issueCommentForm from './issue_comment_form.vue';
import placeholderNote from './issue_placeholder_note.vue';
import placeholderSystemNote from './issue_placeholder_system_note.vue';
import loadingIcon from '../../vue_shared/components/loading_icon.vue';
export default {
name: 'issueNotesApp',
props: {
issueData: {
type: Object,
required: true,
},
notesData: {
type: Object,
required: true,
},
userData: {
type: Object,
required: false,
default: {},
},
},
store,
data() {
return {
isLoading: true,
};
},
components: {
issueNote,
issueDiscussion,
issueSystemNote,
issueCommentForm,
loadingIcon,
placeholderNote,
placeholderSystemNote,
},
computed: {
...mapGetters([
'notes',
'getNotesDataByProp',
]),
},
methods: {
...mapActions({
actionFetchNotes: 'fetchNotes',
poll: 'poll',
actionToggleAward: 'toggleAward',
scrollToNoteIfNeeded: 'scrollToNoteIfNeeded',
setNotesData: 'setNotesData',
setIssueData: 'setIssueData',
setUserData: 'setUserData',
setLastFetchedAt: 'setLastFetchedAt',
setTargetNoteHash: 'setTargetNoteHash',
}),
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 issueDiscussion;
},
getComponentData(note) {
return note.individual_note ? note.notes[0] : note;
},
fetchNotes() {
return this.actionFetchNotes(this.getNotesDataByProp('discussionsPath'))
.then(() => this.initPolling())
.then(() => {
this.isLoading = false;
})
.then(() => this.$nextTick())
.then(() => this.checkLocationHash())
.catch(() => {
this.isLoading = false;
Flash('Something went wrong while fetching issue comments. Please try again.');
});
},
initPolling() {
this.setLastFetchedAt(this.getNotesDataByProp('lastFetchedAt'));
this.poll();
},
checkLocationHash() {
const hash = gl.utils.getLocationHash();
const element = document.getElementById(hash);
if (hash && element) {
this.setTargetNoteHash(hash);
this.scrollToNoteIfNeeded($(element));
}
},
},
created() {
this.setNotesData(this.notesData);
this.setIssueData(this.issueData);
this.setUserData(this.userData);
},
mounted() {
this.fetchNotes();
const parentElement = this.$el.parentElement;
if (parentElement &&
parentElement.classList.contains('js-vue-notes-event')) {
parentElement.addEventListener('toggleAward', (event) => {
const { awardName, noteId } = event.detail;
this.actionToggleAward({ awardName, noteId });
});
}
},
};
</script>
<template>
<div id="notes">
<div
v-if="isLoading"
class="js-loading loading">
<loading-icon />
</div>
<ul
v-if="!isLoading"
id="notes-list"
class="notes main-notes-list timeline">
<component
v-for="note in notes"
:is="getComponentName(note)"
:note="getComponentData(note)"
:key="note.id"
/>
</ul>
<issue-comment-form />
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
export default {
name: 'issuePlaceholderNote',
props: {
note: {
type: Object,
required: true,
},
},
components: {
userAvatarLink,
},
computed: {
...mapGetters([
'getUserData',
]),
},
};
</script>
<template>
<li class="note being-posted fade-in-half timeline-entry">
<div class="timeline-entry-inner">
<div class="timeline-icon">
<user-avatar-link
:link-href="getUserData.path"
:img-src="getUserData.avatar_url"
:img-size="40"
/>
</div>
<div
:class="{ discussion: !note.individual_note }"
class="timeline-content">
<div class="note-header">
<div class="note-header-info">
<a :href="getUserData.path">
<span class="hidden-xs">{{getUserData.name}}</span>
<span class="note-headline-light">@{{getUserData.username}}</span>
</a>
</div>
</div>
<div class="note-body">
<div class="note-text">
<p>{{note.body}}</p>
</div>
</div>
</div>
</div>
</li>
</template>
<script>
export default {
name: 'placeholderSystemNote',
props: {
note: {
type: Object,
required: true,
},
},
};
</script>
<template>
<li class="note system-note timeline-entry being-posted fade-in-half">
<div class="timeline-entry-inner">
<div class="timeline-content">
<em>{{note.body}}</em>
</div>
</div>
</li>
</template>
<script>
import { mapGetters } from 'vuex';
import iconsMap from './issue_note_icons';
import issueNoteHeader from './issue_note_header.vue';
export default {
name: 'systemNote',
props: {
note: {
type: Object,
required: true,
},
},
components: {
issueNoteHeader,
},
computed: {
...mapGetters([
'targetNoteHash',
]),
noteAnchorId() {
return `note_${this.note.id}`;
},
isTargetNote() {
return this.targetNoteHash === this.noteAnchorId;
},
},
created() {
this.svg = iconsMap[this.note.system_note_icon_name];
},
};
</script>
<template>
<li
:id="noteAnchorId"
:class="{ target: isTargetNote }"
class="note system-note timeline-entry">
<div class="timeline-entry-inner">
<div
class="timeline-icon"
v-html="svg">
</div>
<div class="timeline-content">
<div class="note-header">
<issue-note-header
:author="note.author"
:created-at="note.created_at"
:note-id="note.id"
:action-text-html="note.note_html" />
</div>
</div>
</div>
</li>
</template>
export const DISCUSSION_NOTE = 'DiscussionNote';
export const DISCUSSION = 'discussion';
export const NOTE = 'note';
export const SYSTEM_NOTE = 'systemNote';
export const COMMENT = 'comment';
export const OPENED = 'opened';
export const REOPENED = 'reopened';
export const CLOSED = 'closed';
export const EMOJI_THUMBSUP = 'thumbsup';
export const EMOJI_THUMBSDOWN = 'thumbsdown';
export const NOTEABLE_TYPE = 'Issue';
import Vue from 'vue';
export default new Vue();
import Vue from 'vue';
import issueNotesApp from './components/issue_notes_app.vue';
document.addEventListener('DOMContentLoaded', () => new Vue({
el: '#js-vue-notes',
components: {
issueNotesApp,
},
data() {
const notesDataset = document.getElementById('js-vue-notes').dataset;
return {
issueData: JSON.parse(notesDataset.issueData),
currentUserData: JSON.parse(notesDataset.currentUserData),
notesData: {
lastFetchedAt: notesDataset.lastFetchedAt,
discussionsPath: notesDataset.discussionsPath,
newSessionPath: notesDataset.newSessionPath,
registerPath: notesDataset.registerPath,
notesPath: notesDataset.notesPath,
markdownDocsPath: notesDataset.markdownDocsPath,
quickActionsDocsPath: notesDataset.quickActionsDocsPath,
},
};
},
render(createElement) {
return createElement('issue-notes-app', {
props: {
issueData: this.issueData,
notesData: this.notesData,
userData: this.currentUserData,
},
});
},
}));
/* globals Autosave */
import '../../autosave';
export default {
methods: {
initAutoSave() {
this.autosave = new Autosave($(this.$refs.noteForm.$refs.textarea), ['Note', 'Issue', this.note.id], 'issue');
},
resetAutoSave() {
this.autosave.reset();
},
setAutoSave() {
this.autosave.save();
},
},
};
import Vue from 'vue';
import VueResource from 'vue-resource';
Vue.use(VueResource);
export default {
fetchNotes(endpoint) {
return Vue.http.get(endpoint);
},
deleteNote(endpoint) {
return Vue.http.delete(endpoint);
},
replyToDiscussion(endpoint, data) {
return Vue.http.post(endpoint, data, { emulateJSON: true });
},
updateNote(endpoint, data) {
return Vue.http.put(endpoint, data, { emulateJSON: true });
},
createNewNote(endpoint, data) {
return Vue.http.post(endpoint, data, { emulateJSON: true });
},
poll(data = {}) {
const { endpoint, lastFetchedAt } = data;
const options = {
headers: {
'X-Last-Fetched-At': lastFetchedAt,
},
};
return Vue.http.get(endpoint, options);
},
toggleAward(endpoint, data) {
return Vue.http.post(endpoint, data, { emulateJSON: true });
},
};
/* global Flash */
import Visibility from 'visibilityjs';
import Poll from '../../lib/utils/poll';
import * as types from './mutation_types';
import * as utils from './utils';
import * as constants from '../constants';
import service from '../services/issue_notes_service';
import loadAwardsHandler from '../../awards_handler';
import sidebarTimeTrackingEventHub from '../../sidebar/event_hub';
let eTagPoll;
export const setNotesData = ({ commit }, data) => commit(types.SET_NOTES_DATA, data);
export const setIssueData = ({ commit }, data) => commit(types.SET_ISSUE_DATA, data);
export const setUserData = ({ commit }, data) => commit(types.SET_USER_DATA, data);
export const setLastFetchedAt = ({ commit }, data) => commit(types.SET_LAST_FETCHED_AT, data);
export const setInitialNotes = ({ commit }, data) => commit(types.SET_INITIAL_NOTES, data);
export const setTargetNoteHash = ({ commit }, data) => commit(types.SET_TARGET_NOTE_HASH, data);
export const toggleDiscussion = ({ commit }, data) => commit(types.TOGGLE_DISCUSSION, data);
export const fetchNotes = ({ commit }, path) => service
.fetchNotes(path)
.then(res => res.json())
.then((res) => {
commit(types.SET_INITIAL_NOTES, res);
});
export const deleteNote = ({ commit }, note) => service
.deleteNote(note.path)
.then(() => {
commit(types.DELETE_NOTE, note);
});
export const updateNote = ({ commit }, { endpoint, note }) => service
.updateNote(endpoint, note)
.then(res => res.json())
.then((res) => {
commit(types.UPDATE_NOTE, res);
});
export const replyToDiscussion = ({ commit }, { endpoint, data }) => service
.replyToDiscussion(endpoint, data)
.then(res => res.json())
.then((res) => {
commit(types.ADD_NEW_REPLY_TO_DISCUSSION, res);
return res;
});
export const createNewNote = ({ commit }, { endpoint, data }) => service
.createNewNote(endpoint, data)
.then(res => res.json())
.then((res) => {
if (!res.errors) {
commit(types.ADD_NEW_NOTE, res);
}
return res;
});
export const removePlaceholderNotes = ({ commit }) =>
commit(types.REMOVE_PLACEHOLDER_NOTES);
export const saveNote = ({ commit, dispatch }, noteData) => {
const { note } = noteData.data.note;
let placeholderText = note;
const hasQuickActions = utils.hasQuickActions(placeholderText);
const replyId = noteData.data.in_reply_to_discussion_id;
const methodToDispatch = replyId ? 'replyToDiscussion' : 'createNewNote';
commit(types.REMOVE_PLACEHOLDER_NOTES); // remove previous placeholders
$('.notes-form .flash-container').hide(); // hide previous flash notification
if (hasQuickActions) {
placeholderText = utils.stripQuickActions(placeholderText);
}
if (placeholderText.length) {
commit(types.SHOW_PLACEHOLDER_NOTE, {
noteBody: placeholderText,
replyId,
});
}
if (hasQuickActions) {
commit(types.SHOW_PLACEHOLDER_NOTE, {
isSystemNote: true,
noteBody: utils.getQuickActionText(note),
replyId,
});
}
return dispatch(methodToDispatch, noteData)
.then((res) => {
const { errors } = res;
const commandsChanges = res.commands_changes;
if (hasQuickActions && errors && Object.keys(errors).length) {
eTagPoll.makeRequest();
$('.js-gfm-input').trigger('clear-commands-cache.atwho');
Flash('Commands applied', 'notice', $(noteData.flashContainer));
}
if (commandsChanges) {
if (commandsChanges.emoji_award) {
const votesBlock = $('.js-awards-block').eq(0);
loadAwardsHandler()
.then((awardsHandler) => {
awardsHandler.addAwardToEmojiBar(votesBlock, commandsChanges.emoji_award);
awardsHandler.scrollToAwards();
})
.catch(() => {
Flash(
'Something went wrong while adding your award. Please try again.',
null,
$(noteData.flashContainer),
);
});
}
if (commandsChanges.spend_time != null || commandsChanges.time_estimate != null) {
sidebarTimeTrackingEventHub.$emit('timeTrackingUpdated', res);
}
}
if (errors && errors.commands_only) {
Flash(errors.commands_only, 'notice', $(noteData.flashContainer));
}
commit(types.REMOVE_PLACEHOLDER_NOTES);
return res;
});
};
const pollSuccessCallBack = (resp, commit, state, getters) => {
if (resp.notes && resp.notes.length) {
const { notesById } = getters;
resp.notes.forEach((note) => {
if (notesById[note.id]) {
commit(types.UPDATE_NOTE, note);
} else if (note.type === constants.DISCUSSION_NOTE) {
const discussion = utils.findNoteObjectById(state.notes, note.discussion_id);
if (discussion) {
commit(types.ADD_NEW_REPLY_TO_DISCUSSION, note);
} else {
commit(types.ADD_NEW_NOTE, note);
}
} else {
commit(types.ADD_NEW_NOTE, note);
}
});
}
commit(types.SET_LAST_FETCHED_AT, resp.lastFetchedAt);
return resp;
};
export const poll = ({ commit, state, getters }) => {
const requestData = { endpoint: state.notesData.notesPath, lastFetchedAt: state.lastFetchedAt };
eTagPoll = new Poll({
resource: service,
method: 'poll',
data: requestData,
successCallback: resp => resp.json()
.then(data => pollSuccessCallBack(data, commit, state, getters)),
errorCallback: () => Flash('Something went wrong while fetching latest comments.'),
});
if (!Visibility.hidden()) {
eTagPoll.makeRequest();
} else {
service.poll(requestData);
}
Visibility.change(() => {
if (!Visibility.hidden()) {
eTagPoll.restart();
} else {
eTagPoll.stop();
}
});
};
export const fetchData = ({ commit, state, getters }) => {
const requestData = { endpoint: state.notesData.notesPath, lastFetchedAt: state.lastFetchedAt };
service.poll(requestData)
.then(resp => resp.json)
.then(data => pollSuccessCallBack(data, commit, state, getters))
.catch(() => Flash('Something went wrong while fetching latest comments.'));
};
export const toggleAward = ({ commit, state, getters, dispatch }, { awardName, noteId }) => {
commit(types.TOGGLE_AWARD, { awardName, note: getters.notesById[noteId] });
};
export const toggleAwardRequest = ({ commit, getters, dispatch }, data) => {
const { endpoint, awardName } = data;
return service
.toggleAward(endpoint, { name: awardName })
.then(res => res.json())
.then(() => {
dispatch('toggleAward', data);
});
};
export const scrollToNoteIfNeeded = (context, el) => {
if (!gl.utils.isInViewport(el[0])) {
gl.utils.scrollToElement(el);
}
};
import _ from 'underscore';
export const notes = state => state.notes;
export const targetNoteHash = state => state.targetNoteHash;
export const getNotesData = state => state.notesData;
export const getNotesDataByProp = state => prop => state.notesData[prop];
export const getIssueData = state => state.issueData;
export const getIssueDataByProp = state => prop => state.issueData[prop];
export const getUserData = state => state.userData || {};
export const getUserDataByProp = state => prop => state.userData && state.userData[prop];
export const notesById = state => state.notes.reduce((acc, note) => {
note.notes.every(n => Object.assign(acc, { [n.id]: n }));
return acc;
}, {});
const reverseNotes = array => array.slice(0).reverse();
const isLastNote = (note, state) => !note.system &&
state.userData && note.author &&
note.author.id === state.userData.id;
export const getCurrentUserLastNote = state => _.flatten(
reverseNotes(state.notes)
.map(note => reverseNotes(note.notes)),
).find(el => isLastNote(el, state));
export const getDiscussionLastNote = state => discussion => reverseNotes(discussion.notes)
.find(el => isLastNote(el, state));
import Vue from 'vue';
import Vuex from 'vuex';
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
Vue.use(Vuex);
export default new Vuex.Store({
state: {
notes: [],
targetNoteHash: null,
lastFetchedAt: null,
// holds endpoints and permissions provided through haml
notesData: {},
userData: {},
issueData: {},
},
actions,
getters,
mutations,
});
export const ADD_NEW_NOTE = 'ADD_NEW_NOTE';
export const ADD_NEW_REPLY_TO_DISCUSSION = 'ADD_NEW_REPLY_TO_DISCUSSION';
export const DELETE_NOTE = 'DELETE_NOTE';
export const REMOVE_PLACEHOLDER_NOTES = 'REMOVE_PLACEHOLDER_NOTES';
export const SET_NOTES_DATA = 'SET_NOTES_DATA';
export const SET_ISSUE_DATA = 'SET_ISSUE_DATA';
export const SET_USER_DATA = 'SET_USER_DATA';
export const SET_INITIAL_NOTES = 'SET_INITIAL_NOTES';
export const SET_LAST_FETCHED_AT = 'SET_LAST_FETCHED_AT';
export const SET_TARGET_NOTE_HASH = 'SET_TARGET_NOTE_HASH';
export const SHOW_PLACEHOLDER_NOTE = 'SHOW_PLACEHOLDER_NOTE';
export const TOGGLE_AWARD = 'TOGGLE_AWARD';
export const TOGGLE_DISCUSSION = 'TOGGLE_DISCUSSION';
export const UPDATE_NOTE = 'UPDATE_NOTE';
import * as utils from './utils';
import * as types from './mutation_types';
import * as constants from '../constants';
export default {
[types.ADD_NEW_NOTE](state, note) {
const { discussion_id, type } = note;
const noteData = {
expanded: true,
id: discussion_id,
individual_note: !(type === constants.DISCUSSION_NOTE),
notes: [note],
reply_id: discussion_id,
};
state.notes.push(noteData);
},
[types.ADD_NEW_REPLY_TO_DISCUSSION](state, note) {
const noteObj = utils.findNoteObjectById(state.notes, note.discussion_id);
if (noteObj) {
noteObj.notes.push(note);
}
},
[types.DELETE_NOTE](state, note) {
const noteObj = utils.findNoteObjectById(state.notes, note.discussion_id);
if (noteObj.individual_note) {
state.notes.splice(state.notes.indexOf(noteObj), 1);
} else {
const comment = utils.findNoteObjectById(noteObj.notes, note.id);
noteObj.notes.splice(noteObj.notes.indexOf(comment), 1);
if (!noteObj.notes.length) {
state.notes.splice(state.notes.indexOf(noteObj), 1);
}
}
},
[types.REMOVE_PLACEHOLDER_NOTES](state) {
const { notes } = state;
for (let i = notes.length - 1; i >= 0; i -= 1) {
const note = notes[i];
const children = note.notes;
if (children.length && !note.individual_note) { // remove placeholder from discussions
for (let j = children.length - 1; j >= 0; j -= 1) {
if (children[j].isPlaceholderNote) {
children.splice(j, 1);
}
}
} else if (note.isPlaceholderNote) { // remove placeholders from state root
notes.splice(i, 1);
}
}
},
[types.SET_NOTES_DATA](state, data) {
Object.assign(state, { notesData: data });
},
[types.SET_ISSUE_DATA](state, data) {
Object.assign(state, { issueData: data });
},
[types.SET_USER_DATA](state, data) {
Object.assign(state, { userData: data });
},
[types.SET_INITIAL_NOTES](state, notesData) {
const notes = [];
notesData.forEach((note) => {
// To support legacy notes, should be very rare case.
if (note.individual_note && note.notes.length > 1) {
note.notes.forEach((n) => {
const nn = Object.assign({}, note);
nn.notes = [n]; // override notes array to only have one item to mimick individual_note
notes.push(nn);
});
} else {
notes.push(note);
}
});
Object.assign(state, { notes });
},
[types.SET_LAST_FETCHED_AT](state, fetchedAt) {
Object.assign(state, { lastFetchedAt: fetchedAt });
},
[types.SET_TARGET_NOTE_HASH](state, hash) {
Object.assign(state, { targetNoteHash: hash });
},
[types.SHOW_PLACEHOLDER_NOTE](state, data) {
let notesArr = state.notes;
if (data.replyId) {
notesArr = utils.findNoteObjectById(notesArr, data.replyId).notes;
}
notesArr.push({
individual_note: true,
isPlaceholderNote: true,
placeholderType: data.isSystemNote ? constants.SYSTEM_NOTE : constants.NOTE,
notes: [
{
body: data.noteBody,
},
],
});
},
[types.TOGGLE_AWARD](state, data) {
const { awardName, note } = data;
const { id, name, username } = state.userData;
const hasEmojiAwardedByCurrentUser = note.award_emoji
.filter(emoji => emoji.name === data.awardName && emoji.user.id === id);
if (hasEmojiAwardedByCurrentUser.length) {
// If current user has awarded this emoji, remove it.
note.award_emoji.splice(note.award_emoji.indexOf(hasEmojiAwardedByCurrentUser[0]), 1);
} else {
note.award_emoji.push({
name: awardName,
user: { id, name, username },
});
}
},
[types.TOGGLE_DISCUSSION](state, { discussionId }) {
const discussion = utils.findNoteObjectById(state.notes, discussionId);
discussion.expanded = !discussion.expanded;
},
[types.UPDATE_NOTE](state, note) {
const noteObj = utils.findNoteObjectById(state.notes, note.discussion_id);
if (noteObj.individual_note) {
noteObj.notes.splice(0, 1, note);
} else {
const comment = utils.findNoteObjectById(noteObj.notes, note.id);
noteObj.notes.splice(noteObj.notes.indexOf(comment), 1, note);
}
},
};
import AjaxCache from '~/lib/utils/ajax_cache';
const REGEX_QUICK_ACTIONS = /^\/\w+.*$/gm;
export const findNoteObjectById = (notes, id) => notes.filter(n => n.id === id)[0];
export const getQuickActionText = (note) => {
let text = 'Applying command';
const quickActions = AjaxCache.get(gl.GfmAutoComplete.dataSources.commands) || [];
const executedCommands = quickActions.filter((command) => {
const commandRegex = new RegExp(`/${command.name}`);
return commandRegex.test(note);
});
if (executedCommands && executedCommands.length) {
if (executedCommands.length > 1) {
text = 'Applying multiple commands';
} else {
const commandDescription = executedCommands[0].description.toLowerCase();
text = `Applying command to ${commandDescription}`;
}
}
return text;
};
export const hasQuickActions = note => REGEX_QUICK_ACTIONS.test(note);
export const stripQuickActions = note => note.replace(REGEX_QUICK_ACTIONS, '').trim();
...@@ -20,7 +20,7 @@ import './shortcuts_navigation'; ...@@ -20,7 +20,7 @@ import './shortcuts_navigation';
Mousetrap.bind('m', this.openSidebarDropdown.bind(this, 'milestone')); Mousetrap.bind('m', this.openSidebarDropdown.bind(this, 'milestone'));
Mousetrap.bind('r', (function(_this) { Mousetrap.bind('r', (function(_this) {
return function() { return function() {
_this.replyWithSelectedText(); _this.replyWithSelectedText(isMergeRequest);
return false; return false;
}; };
})(this)); })(this));
...@@ -38,9 +38,15 @@ import './shortcuts_navigation'; ...@@ -38,9 +38,15 @@ import './shortcuts_navigation';
} }
} }
ShortcutsIssuable.prototype.replyWithSelectedText = function() { ShortcutsIssuable.prototype.replyWithSelectedText = function(isMergeRequest) {
var quote, documentFragment, el, selected, separator; var quote, documentFragment, el, selected, separator;
var replyField = $('.js-main-target-form #note_note'); let replyField;
if (isMergeRequest) {
replyField = $('.js-main-target-form #note_note');
} else {
replyField = $('.js-main-target-form .js-vue-comment-form');
}
documentFragment = window.gl.utils.getSelectedFragment(); documentFragment = window.gl.utils.getSelectedFragment();
if (!documentFragment) { if (!documentFragment) {
...@@ -57,6 +63,7 @@ import './shortcuts_navigation'; ...@@ -57,6 +63,7 @@ import './shortcuts_navigation';
quote = _.map(selected.split("\n"), function(val) { quote = _.map(selected.split("\n"), function(val) {
return ("> " + val).trim() + "\n"; return ("> " + val).trim() + "\n";
}); });
// If replyField already has some content, add a newline before our quote // If replyField already has some content, add a newline before our quote
separator = replyField.val().trim() !== "" && "\n\n" || ''; separator = replyField.val().trim() !== "" && "\n\n" || '';
replyField.val(function(a, current) { replyField.val(function(a, current) {
...@@ -64,7 +71,7 @@ import './shortcuts_navigation'; ...@@ -64,7 +71,7 @@ import './shortcuts_navigation';
}); });
// Trigger autosave // Trigger autosave
replyField.trigger('input'); replyField.trigger('input').trigger('change');
// Trigger autosize // Trigger autosize
var event = document.createEvent('Event'); var event = document.createEvent('Event');
......
...@@ -6,6 +6,7 @@ import timeTracker from './time_tracker'; ...@@ -6,6 +6,7 @@ import timeTracker from './time_tracker';
import Store from '../../stores/sidebar_store'; import Store from '../../stores/sidebar_store';
import Mediator from '../../sidebar_mediator'; import Mediator from '../../sidebar_mediator';
import eventHub from '../../event_hub';
export default { export default {
data() { data() {
...@@ -20,6 +21,9 @@ export default { ...@@ -20,6 +21,9 @@ export default {
methods: { methods: {
listenForQuickActions() { listenForQuickActions() {
$(document).on('ajax:success', '.gfm-form', this.quickActionListened); $(document).on('ajax:success', '.gfm-form', this.quickActionListened);
eventHub.$on('timeTrackingUpdated', (data) => {
this.quickActionListened(null, data);
});
}, },
quickActionListened(e, data) { quickActionListened(e, data) {
const subscribedCommands = ['spend_time', 'time_estimate']; const subscribedCommands = ['spend_time', 'time_estimate'];
......
<script>
export default {
name: 'confidentialIssueWarning',
};
</script>
<template>
<div class="confidential-issue-warning">
<i
aria-hidden="true"
class="fa fa-eye-slash">
</i>
<span>
This is a confidential issue. Your comment will not be visible to the public.
</span>
</div>
</template>
...@@ -5,19 +5,30 @@ ...@@ -5,19 +5,30 @@
export default { export default {
props: { props: {
markdownPreviewUrl: { markdownPreviewPath: {
type: String, type: String,
required: false, required: false,
default: '', default: '',
}, },
markdownDocs: { markdownDocsPath: {
type: String, type: String,
required: true, required: true,
}, },
addSpacingClasses: {
type: Boolean,
required: false,
default: true,
},
quickActionsDocsPath: {
type: String,
required: false,
},
}, },
data() { data() {
return { return {
markdownPreview: '', markdownPreview: '',
referencedCommands: '',
referencedUsers: '',
markdownPreviewLoading: false, markdownPreviewLoading: false,
previewMarkdown: false, previewMarkdown: false,
}; };
...@@ -26,35 +37,48 @@ ...@@ -26,35 +37,48 @@
markdownHeader, markdownHeader,
markdownToolbar, markdownToolbar,
}, },
computed: {
shouldShowReferencedUsers() {
const referencedUsersThreshold = 10;
return this.referencedUsers.length >= referencedUsersThreshold;
},
},
methods: { methods: {
toggleMarkdownPreview() { toggleMarkdownPreview() {
this.previewMarkdown = !this.previewMarkdown; this.previewMarkdown = !this.previewMarkdown;
/*
Can't use `$refs` as the component is technically in the parent component
so we access the VNode & then get the element
*/
const text = this.$slots.textarea[0].elm.value;
if (!this.previewMarkdown) { if (!this.previewMarkdown) {
this.markdownPreview = ''; this.markdownPreview = '';
} else { } else if (text) {
this.markdownPreviewLoading = true; this.markdownPreviewLoading = true;
this.$http.post( this.$http.post(this.markdownPreviewPath, { text })
this.markdownPreviewUrl, .then(resp => resp.json())
{ .then((data) => {
/* this.renderMarkdown(data);
Can't use `$refs` as the component is technically in the parent component })
so we access the VNode & then get the element .catch(() => new Flash('Error loading markdown preview'));
*/ } else {
text: this.$slots.textarea[0].elm.value, this.renderMarkdown();
}, }
) },
.then(resp => resp.json()) renderMarkdown(data = {}) {
.then((data) => { this.markdownPreviewLoading = false;
this.markdownPreviewLoading = false; this.markdownPreview = data.body || 'Nothing to preview.';
this.markdownPreview = data.body;
this.$nextTick(() => { if (data.references) {
$(this.$refs['markdown-preview']).renderGFM(); this.referencedCommands = data.references.commands;
}); this.referencedUsers = data.references.users;
})
.catch(() => new Flash('Error loading markdown preview'));
} }
this.$nextTick(() => {
$(this.$refs['markdown-preview']).renderGFM();
});
}, },
}, },
mounted() { mounted() {
...@@ -74,7 +98,8 @@ ...@@ -74,7 +98,8 @@
<template> <template>
<div <div
class="md-area prepend-top-default append-bottom-default js-vue-markdown-field" class="md-area js-vue-markdown-field"
:class="{ 'prepend-top-default append-bottom-default': addSpacingClasses }"
ref="gl-form"> ref="gl-form">
<markdown-header <markdown-header
:preview-markdown="previewMarkdown" :preview-markdown="previewMarkdown"
...@@ -94,7 +119,9 @@ ...@@ -94,7 +119,9 @@
</i> </i>
</a> </a>
<markdown-toolbar <markdown-toolbar
:markdown-docs="markdownDocs" /> :markdown-docs-path="markdownDocsPath"
:quick-actions-docs-path="quickActionsDocsPath"
/>
</div> </div>
</div> </div>
<div <div
...@@ -108,5 +135,27 @@ ...@@ -108,5 +135,27 @@
Loading... Loading...
</span> </span>
</div> </div>
<template v-if="previewMarkdown && !markdownPreviewLoading">
<div
v-if="referencedCommands"
v-html="referencedCommands"
class="referenced-commands"></div>
<div
v-if="shouldShowReferencedUsers"
class="referenced-users">
<span>
<i
class="fa fa-exclamation-triangle"
aria-hidden="true">
</i>
You are about to add
<strong>
<span class="js-referenced-users-count">
{{referencedUsers.length}}
</span>
</strong> people to the discussion. Proceed with caution.
</span>
</div>
</template>
</div> </div>
</template> </template>
<script> <script>
export default { export default {
props: { props: {
markdownDocs: { markdownDocsPath: {
type: String, type: String,
required: true, required: true,
}, },
quickActionsDocsPath: {
type: String,
required: false,
},
}, },
}; };
</script> </script>
...@@ -12,22 +16,77 @@ ...@@ -12,22 +16,77 @@
<template> <template>
<div class="comment-toolbar clearfix"> <div class="comment-toolbar clearfix">
<div class="toolbar-text"> <div class="toolbar-text">
<a <template v-if="!quickActionsDocsPath && markdownDocsPath">
:href="markdownDocs" <a
target="_blank" :href="markdownDocsPath"
tabindex="-1"> target="_blank"
Markdown is supported tabindex="-1">
</a> Markdown is supported
</a>
</template>
<template v-if="quickActionsDocsPath && markdownDocsPath">
<a
:href="markdownDocsPath"
target="_blank"
tabindex="-1">
Markdown
</a>
and
<a
:href="quickActionsDocsPath"
target="_blank"
tabindex="-1">
quick actions
</a>
are supported
</template>
</div> </div>
<button <span class="uploading-container">
class="toolbar-button markdown-selector" <span class="uploading-progress-container hide">
type="button" <i
tabindex="-1"> class="fa fa-file-image-o toolbar-button-icon"
<i aria-hidden="true"></i>
class="fa fa-file-image-o toolbar-button-icon" <span class="attaching-file-message"></span>
aria-hidden="true"> <span class="uploading-progress">0%</span>
</i> <span class="uploading-spinner">
Attach a file <i
</button> class="fa fa-spinner fa-spin toolbar-button-icon"
aria-hidden="true"></i>
</span>
</span>
<span class="uploading-error-container hide">
<span class="uploading-error-icon">
<i
class="fa fa-file-image-o toolbar-button-icon"
aria-hidden="true"></i>
</span>
<span class="uploading-error-message"></span>
<button
class="retry-uploading-link"
type="button">
Try again
</button>
or
<button
class="attach-new-file markdown-selector"
type="button">
attach a new file
</button>
</span>
<button
class="markdown-selector button-attach-file"
tabindex="-1"
type="button">
<i
class="fa fa-file-image-o toolbar-button-icon"
aria-hidden="true"></i>
Attach a file
</button>
<button
class="btn btn-default btn-xs hide button-cancel-uploading-files"
type="button">
Cancel
</button>
</span>
</div> </div>
</template> </template>
...@@ -368,6 +368,10 @@ ...@@ -368,6 +368,10 @@
transform: translateY(0); transform: translateY(0);
} }
.comment-type-dropdown.open .dropdown-menu {
display: block;
}
.filtered-search-box-input-container { .filtered-search-box-input-container {
.dropdown-menu, .dropdown-menu,
.dropdown-menu-nav { .dropdown-menu-nav {
......
...@@ -498,6 +498,7 @@ ...@@ -498,6 +498,7 @@
color: $gray-darkest; color: $gray-darkest;
display: block; display: block;
margin: 16px 0 0; margin: 16px 0 0;
font-size: 85%;
.author_link { .author_link {
color: $gray-darkest; color: $gray-darkest;
......
...@@ -250,6 +250,10 @@ ul.related-merge-requests > li { ...@@ -250,6 +250,10 @@ ul.related-merge-requests > li {
} }
} }
.discussion-reply-holder .note-edit-form {
display: block;
}
@media (min-width: $screen-sm-min) { @media (min-width: $screen-sm-min) {
.emoji-block .row { .emoji-block .row {
display: flex; display: flex;
......
...@@ -20,10 +20,6 @@ ...@@ -20,10 +20,6 @@
} }
} }
.new-note {
display: none;
}
.new-note, .new-note,
.note-edit-form { .note-edit-form {
.note-form-actions { .note-form-actions {
...@@ -202,6 +198,10 @@ ...@@ -202,6 +198,10 @@
.discussion-reply-holder { .discussion-reply-holder {
background-color: $white-light; background-color: $white-light;
padding: 10px 16px; padding: 10px 16px;
&.is-replying {
padding-bottom: $gl-padding;
}
} }
} }
......
...@@ -100,6 +100,20 @@ ul.notes { ...@@ -100,6 +100,20 @@ ul.notes {
} }
} }
.editing-spinner {
display: none;
}
&.is-requesting {
.note-timestamp {
display: none;
}
.editing-spinner {
display: inline-block;
}
}
&.is-editing { &.is-editing {
.note-header, .note-header,
.note-text, .note-text,
...@@ -402,6 +416,10 @@ ul.notes { ...@@ -402,6 +416,10 @@ ul.notes {
.note-header-info { .note-header-info {
min-width: 0; min-width: 0;
padding-bottom: 8px; padding-bottom: 8px;
&.discussion {
padding-bottom: 0;
}
} }
.system-note .note-header-info { .system-note .note-header-info {
...@@ -814,10 +832,6 @@ ul.notes { ...@@ -814,10 +832,6 @@ ul.notes {
} }
} }
.discussion-notes .flash-container {
margin-bottom: 0;
}
// Merge request notes in diffs // Merge request notes in diffs
.diff-file { .diff-file {
// Diff is inline // Diff is inline
......
...@@ -3,6 +3,7 @@ module NotesActions ...@@ -3,6 +3,7 @@ module NotesActions
extend ActiveSupport::Concern extend ActiveSupport::Concern
included do included do
before_action :set_polling_interval_header, only: [:index]
before_action :authorize_admin_note!, only: [:update, :destroy] before_action :authorize_admin_note!, only: [:update, :destroy]
before_action :note_project, only: [:create] before_action :note_project, only: [:create]
end end
...@@ -12,14 +13,18 @@ module NotesActions ...@@ -12,14 +13,18 @@ module NotesActions
notes_json = { notes: [], last_fetched_at: current_fetched_at } notes_json = { notes: [], last_fetched_at: current_fetched_at }
@notes = notes_finder.execute.inc_relations_for_view notes = notes_finder.execute
@notes = prepare_notes_for_rendering(@notes) .inc_relations_for_view
.reject { |n| n.cross_reference_not_visible_for?(current_user) }
@notes.each do |note| notes = prepare_notes_for_rendering(notes)
next if note.cross_reference_not_visible_for?(current_user)
notes_json[:notes] << note_json(note) notes_json[:notes] =
end if noteable.discussions_rendered_on_frontend?
note_serializer.represent(notes)
else
notes.map { |note| note_json(note) }
end
render json: notes_json render json: notes_json
end end
...@@ -82,22 +87,27 @@ module NotesActions ...@@ -82,22 +87,27 @@ module NotesActions
} }
if note.persisted? if note.persisted?
attrs.merge!( attrs[:valid] = true
valid: true,
id: note.id,
discussion_id: note.discussion_id(noteable),
html: note_html(note),
note: note.note
)
discussion = note.to_discussion(noteable) if noteable.nil? || noteable.discussions_rendered_on_frontend?
unless discussion.individual_note? attrs.merge!(note_serializer.represent(note))
else
attrs.merge!( attrs.merge!(
discussion_resolvable: discussion.resolvable?, id: note.id,
discussion_id: note.discussion_id(noteable),
diff_discussion_html: diff_discussion_html(discussion), html: note_html(note),
discussion_html: discussion_html(discussion) note: note.note
) )
discussion = note.to_discussion(noteable)
unless discussion.individual_note?
attrs.merge!(
discussion_resolvable: discussion.resolvable?,
diff_discussion_html: diff_discussion_html(discussion),
discussion_html: discussion_html(discussion)
)
end
end end
else else
attrs.merge!( attrs.merge!(
...@@ -168,6 +178,10 @@ module NotesActions ...@@ -168,6 +178,10 @@ module NotesActions
) )
end end
def set_polling_interval_header
Gitlab::PollingInterval.set_header(response, interval: 6_000)
end
def noteable def noteable
@noteable ||= notes_finder.target @noteable ||= notes_finder.target
end end
...@@ -180,6 +194,10 @@ module NotesActions ...@@ -180,6 +194,10 @@ module NotesActions
@notes_finder ||= NotesFinder.new(project, current_user, finder_params) @notes_finder ||= NotesFinder.new(project, current_user, finder_params)
end end
def note_serializer
NoteSerializer.new(project: project, noteable: noteable, current_user: current_user)
end
def note_project def note_project
return @note_project if defined?(@note_project) return @note_project if defined?(@note_project)
return nil unless project return nil unless project
......
...@@ -91,11 +91,25 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -91,11 +91,25 @@ class Projects::IssuesController < Projects::ApplicationController
respond_to do |format| respond_to do |format|
format.html format.html
format.json do format.json do
render json: IssueSerializer.new.represent(@issue) render json: serializer.represent(@issue)
end end
end end
end end
def discussions
notes = @issue.notes
.inc_relations_for_view
.includes(:noteable)
.fresh
.reject { |n| n.cross_reference_not_visible_for?(current_user) }
prepare_notes_for_rendering(notes)
discussions = Discussion.build_collection(notes, @issue)
render json: DiscussionSerializer.new(project: @project, noteable: @issue, current_user: current_user).represent(discussions)
end
def create def create
create_params = issue_params.merge(spammable_params).merge( create_params = issue_params.merge(spammable_params).merge(
merge_request_to_resolve_discussions_of: params[:merge_request_to_resolve_discussions_of], merge_request_to_resolve_discussions_of: params[:merge_request_to_resolve_discussions_of],
...@@ -143,7 +157,7 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -143,7 +157,7 @@ class Projects::IssuesController < Projects::ApplicationController
format.json do format.json do
if @issue.valid? if @issue.valid?
render json: IssueSerializer.new.represent(@issue) render json: serializer.represent(@issue)
else else
render json: { errors: @issue.errors.full_messages }, status: :unprocessable_entity render json: { errors: @issue.errors.full_messages }, status: :unprocessable_entity
end end
...@@ -287,4 +301,8 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -287,4 +301,8 @@ class Projects::IssuesController < Projects::ApplicationController
redirect_to new_user_session_path, notice: notice redirect_to new_user_session_path, notice: notice
end end
def serializer
IssueSerializer.new(current_user: current_user, project: issue.project)
end
end end
...@@ -35,7 +35,7 @@ module IssuablesHelper ...@@ -35,7 +35,7 @@ module IssuablesHelper
def serialize_issuable(issuable) def serialize_issuable(issuable)
case issuable case issuable
when Issue when Issue
IssueSerializer.new.represent(issuable).to_json IssueSerializer.new(current_user: current_user, project: issuable.project).represent(issuable).to_json
when MergeRequest when MergeRequest
MergeRequestSerializer MergeRequestSerializer
.new(current_user: current_user, project: issuable.project) .new(current_user: current_user, project: issuable.project)
...@@ -210,9 +210,9 @@ module IssuablesHelper ...@@ -210,9 +210,9 @@ module IssuablesHelper
canMove: current_user ? issuable.can_move?(current_user) : false, canMove: current_user ? issuable.can_move?(current_user) : false,
issuableRef: issuable.to_reference, issuableRef: issuable.to_reference,
isConfidential: issuable.confidential, isConfidential: issuable.confidential,
markdownPreviewUrl: preview_markdown_path(@project), markdownPreviewPath: preview_markdown_path(@project),
markdownDocs: help_page_path('user/markdown'), markdownDocsPath: help_page_path('user/markdown'),
projectsAutocompleteUrl: autocomplete_projects_path(project_id: @project.id), projectsAutocompletePath: autocomplete_projects_path(project_id: @project.id),
issuableTemplates: issuable_templates(issuable), issuableTemplates: issuable_templates(issuable),
projectPath: ref_project.path, projectPath: ref_project.path,
projectNamespace: ref_project.namespace.full_path, projectNamespace: ref_project.namespace.full_path,
......
...@@ -137,7 +137,7 @@ module IssuesHelper ...@@ -137,7 +137,7 @@ module IssuesHelper
end end
def awards_sort(awards) def awards_sort(awards)
awards.sort_by do |award, notes| awards.sort_by do |award, award_emojis|
if award == "thumbsup" if award == "thumbsup"
0 0
elsif award == "thumbsdown" elsif award == "thumbsdown"
......
...@@ -93,11 +93,13 @@ module NotesHelper ...@@ -93,11 +93,13 @@ module NotesHelper
end end
end end
def notes_url def notes_url(params = {})
if @snippet.is_a?(PersonalSnippet) if @snippet.is_a?(PersonalSnippet)
snippet_notes_path(@snippet) snippet_notes_path(@snippet, params)
else else
project_noteable_notes_path(@project, target_id: @noteable.id, target_type: @noteable.class.name.underscore) params.merge!(target_id: @noteable.id, target_type: @noteable.class.name.underscore)
project_noteable_notes_path(@project, params)
end end
end end
......
...@@ -22,8 +22,14 @@ module SystemNoteHelper ...@@ -22,8 +22,14 @@ module SystemNoteHelper
'duplicate' => 'icon_clone' 'duplicate' => 'icon_clone'
}.freeze }.freeze
def system_note_icon_name(note)
ICON_NAMES_BY_ACTION[note.system_note_metadata&.action]
end
def icon_for_system_note(note) def icon_for_system_note(note)
icon_name = ICON_NAMES_BY_ACTION[note.system_note_metadata&.action] icon_name = system_note_icon_name(note)
custom_icon(icon_name) if icon_name custom_icon(icon_name) if icon_name
end end
extend self
end end
...@@ -17,6 +17,9 @@ class AwardEmoji < ActiveRecord::Base ...@@ -17,6 +17,9 @@ class AwardEmoji < ActiveRecord::Base
scope :downvotes, -> { where(name: DOWNVOTE_NAME) } scope :downvotes, -> { where(name: DOWNVOTE_NAME) }
scope :upvotes, -> { where(name: UPVOTE_NAME) } scope :upvotes, -> { where(name: UPVOTE_NAME) }
after_save :expire_etag_cache
after_destroy :expire_etag_cache
class << self class << self
def votes_for_collection(ids, type) def votes_for_collection(ids, type)
select('name', 'awardable_id', 'COUNT(*) as count') select('name', 'awardable_id', 'COUNT(*) as count')
...@@ -32,4 +35,8 @@ class AwardEmoji < ActiveRecord::Base ...@@ -32,4 +35,8 @@ class AwardEmoji < ActiveRecord::Base
def upvote? def upvote?
self.name == UPVOTE_NAME self.name == UPVOTE_NAME
end end
def expire_etag_cache
awardable.try(:expire_etag_cache)
end
end end
...@@ -24,6 +24,10 @@ module Noteable ...@@ -24,6 +24,10 @@ module Noteable
DiscussionNote::NOTEABLE_TYPES.include?(base_class_name) DiscussionNote::NOTEABLE_TYPES.include?(base_class_name)
end end
def discussions_rendered_on_frontend?
false
end
def discussion_notes def discussion_notes
notes notes
end end
...@@ -38,7 +42,7 @@ module Noteable ...@@ -38,7 +42,7 @@ module Noteable
def grouped_diff_discussions(*args) def grouped_diff_discussions(*args)
# Doesn't use `discussion_notes`, because this may include commit diff notes # Doesn't use `discussion_notes`, because this may include commit diff notes
# besides MR diff notes, that we do no want to display on the MR Changes tab. # besides MR diff notes, that we do not want to display on the MR Changes tab.
notes.inc_relations_for_view.grouped_diff_discussions(*args) notes.inc_relations_for_view.grouped_diff_discussions(*args)
end end
......
...@@ -81,6 +81,10 @@ class Discussion ...@@ -81,6 +81,10 @@ class Discussion
last_note.author last_note.author
end end
def updated?
last_updated_at != created_at
end
def id def id
first_note.discussion_id(context_noteable) first_note.discussion_id(context_noteable)
end end
......
...@@ -269,6 +269,10 @@ class Issue < ActiveRecord::Base ...@@ -269,6 +269,10 @@ class Issue < ActiveRecord::Base
end end
end end
def discussions_rendered_on_frontend?
true
end
def update_project_counter_caches? def update_project_counter_caches?
state_changed? || confidential_changed? state_changed? || confidential_changed?
end end
......
...@@ -299,6 +299,17 @@ class Note < ActiveRecord::Base ...@@ -299,6 +299,17 @@ class Note < ActiveRecord::Base
end end
end end
def expire_etag_cache
return unless noteable&.discussions_rendered_on_frontend?
key = Gitlab::Routing.url_helpers.project_noteable_notes_path(
project,
target_type: noteable_type.underscore,
target_id: noteable_id
)
Gitlab::EtagCaching::Store.new.touch(key)
end
private private
def keep_around_commit def keep_around_commit
...@@ -326,15 +337,4 @@ class Note < ActiveRecord::Base ...@@ -326,15 +337,4 @@ class Note < ActiveRecord::Base
def set_discussion_id def set_discussion_id
self.discussion_id ||= discussion_class.discussion_id(self) self.discussion_id ||= discussion_class.discussion_id(self)
end end
def expire_etag_cache
return unless for_issue?
key = Gitlab::Routing.url_helpers.project_noteable_notes_path(
noteable.project,
target_type: noteable_type.underscore,
target_id: noteable.id
)
Gitlab::EtagCaching::Store.new.touch(key)
end
end end
class AwardEmojiEntity < Grape::Entity
expose :name
expose :user, using: API::Entities::UserSafe
end
class DiscussionEntity < Grape::Entity
include RequestAwareEntity
expose :id, :reply_id
expose :expanded?, as: :expanded
expose :notes, using: NoteEntity
expose :individual_note?, as: :individual_note
end
class DiscussionSerializer < BaseSerializer
entity DiscussionEntity
end
...@@ -15,4 +15,6 @@ class IssuableEntity < Grape::Entity ...@@ -15,4 +15,6 @@ class IssuableEntity < Grape::Entity
expose :total_time_spent expose :total_time_spent
expose :human_time_estimate expose :human_time_estimate
expose :human_total_time_spent expose :human_total_time_spent
expose :milestone, using: API::Entities::Milestone
expose :labels, using: LabelEntity
end end
...@@ -7,10 +7,26 @@ class IssueEntity < IssuableEntity ...@@ -7,10 +7,26 @@ class IssueEntity < IssuableEntity
expose :due_date expose :due_date
expose :moved_to_id expose :moved_to_id
expose :project_id expose :project_id
expose :milestone, using: API::Entities::Milestone
expose :labels, using: LabelEntity
expose :web_url do |issue| expose :web_url do |issue|
project_issue_path(issue.project, issue) project_issue_path(issue.project, issue)
end end
expose :current_user do
expose :can_create_note do |issue|
can?(request.current_user, :create_note, issue.project)
end
expose :can_update do |issue|
can?(request.current_user, :update_issue, issue)
end
end
expose :create_note_path do |issue|
project_notes_path(issue.project, target_type: 'issue', target_id: issue.id)
end
expose :preview_note_path do |issue|
preview_markdown_path(issue.project, quick_actions_target_type: 'Issue', quick_actions_target_id: issue.id)
end
end end
class NoteAttachmentEntity < Grape::Entity
expose :url
expose :filename
expose :image?, as: :image
end
class NoteEntity < API::Entities::Note
include RequestAwareEntity
expose :type
expose :author, using: NoteUserEntity
expose :human_access do |note|
note.project.team.human_max_access(note.author_id)
end
unexpose :note, as: :body
expose :note
expose :redacted_note_html, as: :note_html
expose :last_edited_at, if: -> (note, _) { note.edited? }
expose :last_edited_by, using: NoteUserEntity, if: -> (note, _) { note.edited? }
expose :current_user do
expose :can_edit do |note|
Ability.can_edit_note?(request.current_user, note)
end
end
expose :system_note_icon_name, if: -> (note, _) { note.system? } do |note|
SystemNoteHelper.system_note_icon_name(note)
end
expose :discussion_id do |note|
note.discussion_id(request.noteable)
end
expose :emoji_awardable?, as: :emoji_awardable
expose :award_emoji, if: -> (note, _) { note.emoji_awardable? }, using: AwardEmojiEntity
expose :toggle_award_path, if: -> (note, _) { note.emoji_awardable? } do |note|
if note.for_personal_snippet?
toggle_award_emoji_snippet_note_path(note.noteable, note)
else
toggle_award_emoji_project_note_path(note.project, note.id)
end
end
expose :report_abuse_path do |note|
new_abuse_report_path(user_id: note.author.id, ref_url: Gitlab::UrlBuilder.build(note))
end
expose :path do |note|
if note.for_personal_snippet?
snippet_note_path(note.noteable, note)
else
project_note_path(note.project, note)
end
end
expose :attachment, using: NoteAttachmentEntity, if: -> (note, _) { note.attachment? }
expose :delete_attachment_path, if: -> (note, _) { note.attachment? } do |note|
delete_attachment_project_note_path(note.project, note)
end
end
class NoteSerializer < BaseSerializer
entity NoteEntity
end
class NoteUserEntity < UserEntity
unexpose :web_url
end
class UserSerializer < BaseSerializer
entity UserEntity
end
...@@ -5,7 +5,7 @@ ...@@ -5,7 +5,7 @@
by by
= link_to_member(@project, discussion.resolved_by, avatar: false) = link_to_member(@project, discussion.resolved_by, avatar: false)
= time_ago_with_tooltip(discussion.resolved_at, placement: "bottom") = time_ago_with_tooltip(discussion.resolved_at, placement: "bottom")
- elsif discussion.last_updated_at != discussion.created_at - elsif discussion.updated?
.discussion-headline-light.js-discussion-headline .discussion-headline-light.js-discussion-headline
Last updated Last updated
- if discussion.last_updated_by - if discussion.last_updated_by
......
- referenced_users = local_assigns.fetch(:referenced_users, nil) - referenced_users = local_assigns.fetch(:referenced_users, nil)
- if defined?(@issue) && @issue.confidential?
.confidential-issue-warning
= confidential_icon(@issue)
%span This is a confidential issue. Your comment will not be visible to the public.
.md-area .md-area
.md-header .md-header
%ul.nav-links.clearfix %ul.nav-links.clearfix
......
- @gfm_form = true
- content_for :note_actions do - content_for :note_actions do
- if can?(current_user, :update_issue, @issue) - if can?(current_user, :update_issue, @issue)
= link_to 'Reopen issue', issue_path(@issue, issue: {state_event: :reopen}, format: 'json'), data: {original_text: "Reopen issue", alternative_text: "Comment & reopen issue"}, class: "btn btn-nr btn-reopen btn-comment js-note-target-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue' = link_to 'Reopen issue', issue_path(@issue, issue: {state_event: :reopen}, format: 'json'), data: {original_text: "Reopen issue", alternative_text: "Comment & reopen issue"}, class: "btn btn-nr btn-reopen btn-comment js-note-target-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue'
= link_to 'Close issue', issue_path(@issue, issue: {state_event: :close}, format: 'json'), data: {original_text: "Close issue", alternative_text: "Comment & close issue"}, class: "btn btn-nr btn-close btn-comment js-note-target-close #{issue_button_visibility(@issue, true)}", title: 'Close issue' = link_to 'Close issue', issue_path(@issue, issue: {state_event: :close}, format: 'json'), data: {original_text: "Close issue", alternative_text: "Comment & close issue"}, class: "btn btn-nr btn-close btn-comment js-note-target-close #{issue_button_visibility(@issue, true)}", title: 'Close issue'
#notes %section.js-vue-notes-event
= render 'shared/notes/notes_with_form', :autocomplete => true #js-vue-notes{ data: { discussions_path: discussions_project_issue_path(@project, @issue, format: :json),
register_path: new_session_path(:user, redirect_to_referer: 'yes', anchor: 'register-pane'),
new_session_path: new_session_path(:user, redirect_to_referer: 'yes'),
markdown_docs_path: help_page_path('user/markdown'),
quick_actions_docs_path: help_page_path('user/project/quick_actions'),
notes_path: notes_url,
last_fetched_at: Time.now.to_i,
issue_data: serialize_issuable(@issue),
current_user_data: UserSerializer.new.represent(current_user).to_json } }
...@@ -2,6 +2,11 @@ ...@@ -2,6 +2,11 @@
- page_title "#{@issue.title} (#{@issue.to_reference})", "Issues" - page_title "#{@issue.title} (#{@issue.to_reference})", "Issues"
- page_description @issue.description - page_description @issue.description
- page_card_attributes @issue.card_attributes - page_card_attributes @issue.card_attributes
- content_for :page_specific_javascripts do
= webpack_bundle_tag 'common_vue'
= webpack_bundle_tag 'notes'
- can_update_issue = can?(current_user, :update_issue, @issue) - can_update_issue = can?(current_user, :update_issue, @issue)
- can_report_spam = @issue.submittable_as_spam_by?(current_user) - can_report_spam = @issue.submittable_as_spam_by?(current_user)
...@@ -23,7 +28,7 @@ ...@@ -23,7 +28,7 @@
= icon('eye-slash', class: 'is-confidential') = icon('eye-slash', class: 'is-confidential')
= issuable_meta(@issue, @project, "Issue") = issuable_meta(@issue, @project, "Issue")
.issuable-actions .issuable-actions.js-issuable-actions
.clearfix.issue-btn-group.dropdown .clearfix.issue-btn-group.dropdown
%button.btn.btn-default.pull-left.hidden-md.hidden-lg{ type: "button", data: { toggle: "dropdown" } } %button.btn.btn-default.pull-left.hidden-md.hidden-lg{ type: "button", data: { toggle: "dropdown" } }
Options Options
...@@ -36,8 +41,8 @@ ...@@ -36,8 +41,8 @@
- if @issue.author && current_user != @issue.author - if @issue.author && current_user != @issue.author
%li= link_to 'Report abuse', new_abuse_report_path(user_id: @issue.author.id, ref_url: issue_url(@issue)) %li= link_to 'Report abuse', new_abuse_report_path(user_id: @issue.author.id, ref_url: issue_url(@issue))
- if can_update_issue - if can_update_issue
%li= link_to 'Close issue', issue_path(@issue, issue: { state_event: :close }, format: 'json'), class: "btn-close #{issue_button_visibility(@issue, true)}", title: 'Close issue' %li= link_to 'Close issue', issue_path(@issue, issue: { state_event: :close }, format: 'json'), class: "btn-close js-btn-issue-action #{issue_button_visibility(@issue, true)}", title: 'Close issue'
%li= link_to 'Reopen issue', issue_path(@issue, issue: { state_event: :reopen }, format: 'json'), class: "btn-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue' %li= link_to 'Reopen issue', issue_path(@issue, issue: { state_event: :reopen }, format: 'json'), class: "btn-reopen js-btn-issue-action #{issue_button_visibility(@issue, false)}", title: 'Reopen issue'
- if can_report_spam - if can_report_spam
%li= link_to 'Submit as spam', mark_as_spam_project_issue_path(@project, @issue), method: :post, class: 'btn-spam', title: 'Submit as spam' %li= link_to 'Submit as spam', mark_as_spam_project_issue_path(@project, @issue), method: :post, class: 'btn-spam', title: 'Submit as spam'
- if can_update_issue || can_report_spam - if can_update_issue || can_report_spam
...@@ -74,7 +79,7 @@ ...@@ -74,7 +79,7 @@
.content-block.emoji-block .content-block.emoji-block
.row .row
.col-sm-8 .col-sm-8.js-issue-note-awards
= render 'award_emoji/awards_block', awardable: @issue, inline: true = render 'award_emoji/awards_block', awardable: @issue, inline: true
.col-sm-4.new-branch-col .col-sm-4.new-branch-col
= render 'new_branch' unless @issue.confidential? = render 'new_branch' unless @issue.confidential?
......
...@@ -17,7 +17,7 @@ ...@@ -17,7 +17,7 @@
.issuable-meta .issuable-meta
= issuable_meta(@merge_request, @project, "Merge request") = issuable_meta(@merge_request, @project, "Merge request")
.issuable-actions .issuable-actions.js-issuable-actions
.clearfix.issue-btn-group.dropdown .clearfix.issue-btn-group.dropdown
%button.btn.btn-default.pull-left.hidden-md.hidden-lg{ type: "button", data: { toggle: "dropdown" } } %button.btn.btn-default.pull-left.hidden-md.hidden-lg{ type: "button", data: { toggle: "dropdown" } }
Options Options
......
...@@ -4,9 +4,9 @@ ...@@ -4,9 +4,9 @@
- if can_update && is_current_user - if can_update && is_current_user
= link_to "Close #{display_issuable_type}", close_issuable_url(issuable), method: button_method, = link_to "Close #{display_issuable_type}", close_issuable_url(issuable), method: button_method,
class: "hidden-xs hidden-sm btn btn-grouped btn-close #{issuable_button_visibility(issuable, true)}", title: "Close #{display_issuable_type}" class: "hidden-xs hidden-sm btn btn-grouped btn-close js-btn-issue-action #{issuable_button_visibility(issuable, true)}", title: "Close #{display_issuable_type}"
= link_to "Reopen #{display_issuable_type}", reopen_issuable_url(issuable), method: button_method, = link_to "Reopen #{display_issuable_type}", reopen_issuable_url(issuable), method: button_method,
class: "hidden-xs hidden-sm btn btn-grouped btn-reopen #{issuable_button_visibility(issuable, false)}", title: "Reopen #{display_issuable_type}" class: "hidden-xs hidden-sm btn btn-grouped btn-reopen js-btn-issue-action #{issuable_button_visibility(issuable, false)}", title: "Reopen #{display_issuable_type}"
- elsif can_update && !is_current_user - elsif can_update && !is_current_user
= render 'shared/issuable/close_reopen_report_toggle', issuable: issuable = render 'shared/issuable/close_reopen_report_toggle', issuable: issuable
- elsif issuable.author - elsif issuable.author
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
- button_action = issuable.closed? ? 'reopen' : 'close' - button_action = issuable.closed? ? 'reopen' : 'close'
- display_button_action = button_action.capitalize - display_button_action = button_action.capitalize
- button_responsive_class = 'hidden-xs hidden-sm' - button_responsive_class = 'hidden-xs hidden-sm'
- button_class = "#{button_responsive_class} btn btn-grouped js-issuable-close-button issuable-close-button" - button_class = "#{button_responsive_class} btn btn-grouped js-issuable-close-button js-btn-issue-action issuable-close-button"
- toggle_class = "#{button_responsive_class} btn btn-nr dropdown-toggle js-issuable-close-toggle" - toggle_class = "#{button_responsive_class} btn btn-nr dropdown-toggle js-issuable-close-toggle"
- button_method = issuable_close_reopen_button_method(issuable) - button_method = issuable_close_reopen_button_method(issuable)
......
...@@ -17,9 +17,9 @@ ...@@ -17,9 +17,9 @@
- elsif !current_user - elsif !current_user
.disabled-comment.text-center.prepend-top-default .disabled-comment.text-center.prepend-top-default
Please Please
= link_to "register", new_session_path(:user, redirect_to_referer: 'yes') = link_to "register", new_session_path(:user, redirect_to_referer: 'yes', anchor: 'register-pane'), class: 'js-register-link'
or or
= link_to "sign in", new_session_path(:user, redirect_to_referer: 'yes') = link_to "sign in", new_session_path(:user, redirect_to_referer: 'yes'), class: 'js-sign-in-link'
to comment to comment
%script.js-notes-data{ type: "application/json" }= initial_notes_data(autocomplete).to_json.html_safe %script.js-notes-data{ type: "application/json" }= initial_notes_data(autocomplete).to_json.html_safe
...@@ -308,6 +308,7 @@ constraints(ProjectUrlConstrainer.new) do ...@@ -308,6 +308,7 @@ constraints(ProjectUrlConstrainer.new) do
get :can_create_branch get :can_create_branch
get :realtime_changes get :realtime_changes
post :create_merge_request post :create_merge_request
get :discussions, format: :json
end end
collection do collection do
post :bulk_update post :bulk_update
......
...@@ -55,6 +55,7 @@ var config = { ...@@ -55,6 +55,7 @@ var config = {
monitoring: './monitoring/monitoring_bundle.js', monitoring: './monitoring/monitoring_bundle.js',
network: './network/network_bundle.js', network: './network/network_bundle.js',
notebook_viewer: './blob/notebook_viewer.js', notebook_viewer: './blob/notebook_viewer.js',
notes: './notes/index.js',
pdf_viewer: './blob/pdf_viewer.js', pdf_viewer: './blob/pdf_viewer.js',
pipelines: './pipelines/pipelines_bundle.js', pipelines: './pipelines/pipelines_bundle.js',
pipelines_charts: './pipelines/pipelines_charts.js', pipelines_charts: './pipelines/pipelines_charts.js',
...@@ -194,6 +195,7 @@ var config = { ...@@ -194,6 +195,7 @@ var config = {
'merge_conflicts', 'merge_conflicts',
'monitoring', 'monitoring',
'notebook_viewer', 'notebook_viewer',
'notes',
'pdf_viewer', 'pdf_viewer',
'pipelines', 'pipelines',
'pipelines_details', 'pipelines_details',
......
...@@ -168,6 +168,7 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps ...@@ -168,6 +168,7 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps
author: project.users.first, author: project.users.first,
description: "# Description header" description: "# Description header"
) )
wait_for_requests
end end
step 'project "Shop" have "Tweet control" open issue' do step 'project "Shop" have "Tweet control" open issue' do
......
...@@ -137,7 +137,7 @@ module SharedNote ...@@ -137,7 +137,7 @@ module SharedNote
step 'The comment with the header should not have an ID' do step 'The comment with the header should not have an ID' do
page.within(".note-body > .note-text") do page.within(".note-body > .note-text") do
expect(page).to have_content("Comment with a header") expect(page).to have_content("Comment with a header")
expect(page).not_to have_css("#comment-with-a-header") expect(page).not_to have_css("#comment-with-a-header")
end end
end end
...@@ -150,15 +150,20 @@ module SharedNote ...@@ -150,15 +150,20 @@ module SharedNote
note.find('.js-note-edit').click note.find('.js-note-edit').click
end end
page.find('.current-note-edit-form textarea')
page.within(".current-note-edit-form") do page.within(".current-note-edit-form") do
fill_in 'note[note]', with: '+1 Awesome!' fill_in 'note[note]', with: '+1 Awesome!'
click_button 'Save comment' click_button 'Save comment'
end end
wait_for_requests
end end
step 'I should see +1 in the description' do step 'I should see +1 in the description' do
page.within(".note") do page.within(".note") do
expect(page).to have_content("+1 Awesome!") expect(page).to have_content("+1 Awesome!")
end end
wait_for_requests
end end
end end
module API module API
module Entities module Entities
class UserSafe < Grape::Entity class UserSafe < Grape::Entity
expose :name, :username expose :id, :name, :username
end end
class UserBasic < UserSafe class UserBasic < UserSafe
expose :id, :state expose :state
expose :avatar_url do |user, options| expose :avatar_url do |user, options|
user.avatar_url(only_path: false) user.avatar_url(only_path: false)
end end
......
...@@ -879,4 +879,19 @@ describe Projects::IssuesController do ...@@ -879,4 +879,19 @@ describe Projects::IssuesController do
format: :json format: :json
end end
end end
describe 'GET #discussions' do
let!(:discussion) { create(:discussion_note_on_issue, noteable: issue, project: issue.project) }
before do
project.add_developer(user)
sign_in(user)
end
it 'returns discussion json' do
get :discussions, namespace_id: project.namespace, project_id: project, id: issue.iid
expect(JSON.parse(response.body).first.keys).to match_array(%w[id reply_id expanded notes individual_note])
end
end
end end
...@@ -46,10 +46,13 @@ describe Projects::NotesController do ...@@ -46,10 +46,13 @@ describe Projects::NotesController do
end end
context 'for a discussion note' do context 'for a discussion note' do
let!(:note) { create(:discussion_note_on_issue, noteable: issue, project: project) } let(:project) { create(:project, :repository) }
let!(:note) { create(:discussion_note_on_merge_request, project: project) }
let(:params) { request_params.merge(target_type: 'merge_request', target_id: note.noteable_id) }
it 'responds with the expected attributes' do it 'responds with the expected attributes' do
get :index, request_params get :index, params
expect(note_json[:id]).to eq(note.id) expect(note_json[:id]).to eq(note.id)
expect(note_json[:discussion_html]).not_to be_nil expect(note_json[:discussion_html]).not_to be_nil
...@@ -104,10 +107,12 @@ describe Projects::NotesController do ...@@ -104,10 +107,12 @@ describe Projects::NotesController do
end end
context 'for a regular note' do context 'for a regular note' do
let!(:note) { create(:note, noteable: issue, project: project) } let!(:note) { create(:note_on_merge_request, project: project) }
let(:params) { request_params.merge(target_type: 'merge_request', target_id: note.noteable_id) }
it 'responds with the expected attributes' do it 'responds with the expected attributes' do
get :index, request_params get :index, params
expect(note_json[:id]).to eq(note.id) expect(note_json[:id]).to eq(note.id)
expect(note_json[:html]).not_to be_nil expect(note_json[:html]).not_to be_nil
...@@ -125,7 +130,9 @@ describe Projects::NotesController do ...@@ -125,7 +130,9 @@ describe Projects::NotesController do
note: { note: 'some note', noteable_id: merge_request.id, noteable_type: 'MergeRequest' }, note: { note: 'some note', noteable_id: merge_request.id, noteable_type: 'MergeRequest' },
namespace_id: project.namespace, namespace_id: project.namespace,
project_id: project, project_id: project,
merge_request_diff_head_sha: 'sha' merge_request_diff_head_sha: 'sha',
target_type: 'merge_request',
target_id: merge_request.id
} }
end end
......
...@@ -70,13 +70,13 @@ describe 'Awards Emoji' do ...@@ -70,13 +70,13 @@ describe 'Awards Emoji' do
it 'toggles the smiley emoji on a note', js: true do it 'toggles the smiley emoji on a note', js: true do
toggle_smiley_emoji(true) toggle_smiley_emoji(true)
within('.note-awards') do within('.note-body') do
expect(find(emoji_counter)).to have_text("1") expect(find(emoji_counter)).to have_text("1")
end end
toggle_smiley_emoji(false) toggle_smiley_emoji(false)
within('.note-awards') do within('.note-body') do
expect(page).not_to have_selector(emoji_counter) expect(page).not_to have_selector(emoji_counter)
end end
end end
......
...@@ -28,8 +28,8 @@ feature 'GFM autocomplete', js: true do ...@@ -28,8 +28,8 @@ feature 'GFM autocomplete', js: true do
it 'opens autocomplete menu when field starts with text' do it 'opens autocomplete menu when field starts with text' do
page.within '.timeline-content-form' do page.within '.timeline-content-form' do
find('#note_note').native.send_keys('') find('#note-body').native.send_keys('')
find('#note_note').native.send_keys('@') find('#note-body').native.send_keys('@')
end end
expect(page).to have_selector('.atwho-container') expect(page).to have_selector('.atwho-container')
...@@ -37,8 +37,8 @@ feature 'GFM autocomplete', js: true do ...@@ -37,8 +37,8 @@ feature 'GFM autocomplete', js: true do
it 'doesnt open autocomplete menu character is prefixed with text' do it 'doesnt open autocomplete menu character is prefixed with text' do
page.within '.timeline-content-form' do page.within '.timeline-content-form' do
find('#note_note').native.send_keys('testing') find('#note-body').native.send_keys('testing')
find('#note_note').native.send_keys('@') find('#note-body').native.send_keys('@')
end end
expect(page).not_to have_selector('.atwho-view') expect(page).not_to have_selector('.atwho-view')
...@@ -46,8 +46,8 @@ feature 'GFM autocomplete', js: true do ...@@ -46,8 +46,8 @@ feature 'GFM autocomplete', js: true do
it 'doesnt select the first item for non-assignee dropdowns' do it 'doesnt select the first item for non-assignee dropdowns' do
page.within '.timeline-content-form' do page.within '.timeline-content-form' do
find('#note_note').native.send_keys('') find('#note-body').native.send_keys('')
find('#note_note').native.send_keys(':') find('#note-body').native.send_keys(':')
end end
expect(page).to have_selector('.atwho-container') expect(page).to have_selector('.atwho-container')
...@@ -58,7 +58,7 @@ feature 'GFM autocomplete', js: true do ...@@ -58,7 +58,7 @@ feature 'GFM autocomplete', js: true do
end end
it 'does not open autocomplete menu when ":" is prefixed by a number and letters' do it 'does not open autocomplete menu when ":" is prefixed by a number and letters' do
note = find('#note_note') note = find('#note-body')
# Number. # Number.
page.within '.timeline-content-form' do page.within '.timeline-content-form' do
...@@ -86,8 +86,8 @@ feature 'GFM autocomplete', js: true do ...@@ -86,8 +86,8 @@ feature 'GFM autocomplete', js: true do
it 'selects the first item for assignee dropdowns' do it 'selects the first item for assignee dropdowns' do
page.within '.timeline-content-form' do page.within '.timeline-content-form' do
find('#note_note').native.send_keys('') find('#note-body').native.send_keys('')
find('#note_note').native.send_keys('@') find('#note-body').native.send_keys('@')
end end
expect(page).to have_selector('.atwho-container') expect(page).to have_selector('.atwho-container')
...@@ -99,8 +99,8 @@ feature 'GFM autocomplete', js: true do ...@@ -99,8 +99,8 @@ feature 'GFM autocomplete', js: true do
it 'includes items for assignee dropdowns with non-ASCII characters in name' do it 'includes items for assignee dropdowns with non-ASCII characters in name' do
page.within '.timeline-content-form' do page.within '.timeline-content-form' do
find('#note_note').native.send_keys('') find('#note-body').native.send_keys('')
find('#note_note').native.send_keys("@#{user.name[0...8]}") find('#note-body').native.send_keys("@#{user.name[0...8]}")
end end
expect(page).to have_selector('.atwho-container') expect(page).to have_selector('.atwho-container')
...@@ -112,8 +112,8 @@ feature 'GFM autocomplete', js: true do ...@@ -112,8 +112,8 @@ feature 'GFM autocomplete', js: true do
it 'selects the first item for non-assignee dropdowns if a query is entered' do it 'selects the first item for non-assignee dropdowns if a query is entered' do
page.within '.timeline-content-form' do page.within '.timeline-content-form' do
find('#note_note').native.send_keys('') find('#note-body').native.send_keys('')
find('#note_note').native.send_keys(':1') find('#note-body').native.send_keys(':1')
end end
expect(page).to have_selector('.atwho-container') expect(page).to have_selector('.atwho-container')
...@@ -125,7 +125,7 @@ feature 'GFM autocomplete', js: true do ...@@ -125,7 +125,7 @@ feature 'GFM autocomplete', js: true do
context 'if a selected value has special characters' do context 'if a selected value has special characters' do
it 'wraps the result in double quotes' do it 'wraps the result in double quotes' do
note = find('#note_note') note = find('#note-body')
page.within '.timeline-content-form' do page.within '.timeline-content-form' do
note.native.send_keys('') note.native.send_keys('')
note.native.send_keys("~#{label.title[0]}") note.native.send_keys("~#{label.title[0]}")
...@@ -138,7 +138,7 @@ feature 'GFM autocomplete', js: true do ...@@ -138,7 +138,7 @@ feature 'GFM autocomplete', js: true do
end end
it "shows dropdown after a new line" do it "shows dropdown after a new line" do
note = find('#note_note') note = find('#note-body')
page.within '.timeline-content-form' do page.within '.timeline-content-form' do
note.native.send_keys('test') note.native.send_keys('test')
note.native.send_keys(:enter) note.native.send_keys(:enter)
...@@ -150,7 +150,7 @@ feature 'GFM autocomplete', js: true do ...@@ -150,7 +150,7 @@ feature 'GFM autocomplete', js: true do
end end
it "does not show dropdown when preceded with a special character" do it "does not show dropdown when preceded with a special character" do
note = find('#note_note') note = find('#note-body')
page.within '.timeline-content-form' do page.within '.timeline-content-form' do
note.native.send_keys('') note.native.send_keys('')
note.native.send_keys("@") note.native.send_keys("@")
...@@ -168,7 +168,7 @@ feature 'GFM autocomplete', js: true do ...@@ -168,7 +168,7 @@ feature 'GFM autocomplete', js: true do
end end
it "does not throw an error if no labels exist" do it "does not throw an error if no labels exist" do
note = find('#note_note') note = find('#note-body')
page.within '.timeline-content-form' do page.within '.timeline-content-form' do
note.native.send_keys('') note.native.send_keys('')
note.native.send_keys('~') note.native.send_keys('~')
...@@ -179,7 +179,7 @@ feature 'GFM autocomplete', js: true do ...@@ -179,7 +179,7 @@ feature 'GFM autocomplete', js: true do
end end
it 'doesn\'t wrap for assignee values' do it 'doesn\'t wrap for assignee values' do
note = find('#note_note') note = find('#note-body')
page.within '.timeline-content-form' do page.within '.timeline-content-form' do
note.native.send_keys('') note.native.send_keys('')
note.native.send_keys("@#{user.username[0]}") note.native.send_keys("@#{user.username[0]}")
...@@ -192,7 +192,7 @@ feature 'GFM autocomplete', js: true do ...@@ -192,7 +192,7 @@ feature 'GFM autocomplete', js: true do
end end
it 'doesn\'t wrap for emoji values' do it 'doesn\'t wrap for emoji values' do
note = find('#note_note') note = find('#note-body')
page.within '.timeline-content-form' do page.within '.timeline-content-form' do
note.native.send_keys('') note.native.send_keys('')
note.native.send_keys(":cartwheel") note.native.send_keys(":cartwheel")
...@@ -206,7 +206,7 @@ feature 'GFM autocomplete', js: true do ...@@ -206,7 +206,7 @@ feature 'GFM autocomplete', js: true do
it 'doesn\'t open autocomplete after non-word character' do it 'doesn\'t open autocomplete after non-word character' do
page.within '.timeline-content-form' do page.within '.timeline-content-form' do
find('#note_note').native.send_keys("@#{user.username[0..2]}!") find('#note-body').native.send_keys("@#{user.username[0..2]}!")
end end
expect(page).not_to have_selector('.atwho-view') expect(page).not_to have_selector('.atwho-view')
...@@ -214,14 +214,14 @@ feature 'GFM autocomplete', js: true do ...@@ -214,14 +214,14 @@ feature 'GFM autocomplete', js: true do
it 'doesn\'t open autocomplete if there is no space before' do it 'doesn\'t open autocomplete if there is no space before' do
page.within '.timeline-content-form' do page.within '.timeline-content-form' do
find('#note_note').native.send_keys("hello:#{user.username[0..2]}") find('#note-body').native.send_keys("hello:#{user.username[0..2]}")
end end
expect(page).not_to have_selector('.atwho-view') expect(page).not_to have_selector('.atwho-view')
end end
it 'triggers autocomplete after selecting a quick action' do it 'triggers autocomplete after selecting a quick action' do
note = find('#note_note') note = find('#note-body')
page.within '.timeline-content-form' do page.within '.timeline-content-form' do
note.native.send_keys('') note.native.send_keys('')
note.native.send_keys('/as') note.native.send_keys('/as')
......
...@@ -12,26 +12,26 @@ feature 'Issue markdown toolbar', js: true do ...@@ -12,26 +12,26 @@ feature 'Issue markdown toolbar', js: true do
end end
it "doesn't include first new line when adding bold" do it "doesn't include first new line when adding bold" do
find('#note_note').native.send_keys('test') find('#note-body').native.send_keys('test')
find('#note_note').native.send_key(:enter) find('#note-body').native.send_key(:enter)
find('#note_note').native.send_keys('bold') find('#note-body').native.send_keys('bold')
page.evaluate_script('document.querySelectorAll(".js-main-target-form #note_note")[0].setSelectionRange(4, 9)') page.evaluate_script('document.querySelectorAll(".js-main-target-form #note-body")[0].setSelectionRange(4, 9)')
first('.toolbar-btn').click first('.toolbar-btn').click
expect(find('#note_note')[:value]).to eq("test\n**bold**\n") expect(find('#note-body')[:value]).to eq("test\n**bold**\n")
end end
it "doesn't include first new line when adding underline" do it "doesn't include first new line when adding underline" do
find('#note_note').native.send_keys('test') find('#note-body').native.send_keys('test')
find('#note_note').native.send_key(:enter) find('#note-body').native.send_key(:enter)
find('#note_note').native.send_keys('underline') find('#note-body').native.send_keys('underline')
page.evaluate_script('document.querySelectorAll(".js-main-target-form #note_note")[0].setSelectionRange(4, 50)') page.evaluate_script('document.querySelectorAll(".js-main-target-form #note-body")[0].setSelectionRange(4, 50)')
find('.toolbar-btn:nth-child(2)').click find('.toolbar-btn:nth-child(2)').click
expect(find('#note_note')[:value]).to eq("test\n*underline*\n") expect(find('#note-body')[:value]).to eq("test\n*underline*\n")
end end
end end
...@@ -13,7 +13,7 @@ feature 'Issue notes polling', :js do ...@@ -13,7 +13,7 @@ feature 'Issue notes polling', :js do
it 'displays the new comment' do it 'displays the new comment' do
note = create(:note, noteable: issue, project: project, note: 'Looks good!') note = create(:note, noteable: issue, project: project, note: 'Looks good!')
page.execute_script('notes.refresh();') wait_for_requests
expect(page).to have_selector("#note_#{note.id}", text: 'Looks good!') expect(page).to have_selector("#note_#{note.id}", text: 'Looks good!')
end end
...@@ -31,16 +31,6 @@ feature 'Issue notes polling', :js do ...@@ -31,16 +31,6 @@ feature 'Issue notes polling', :js do
visit project_issue_path(project, issue) visit project_issue_path(project, issue)
end end
it 'has .original-note-content to compare against' do
expect(page).to have_selector("#note_#{existing_note.id}", text: note_text)
expect(page).to have_selector("#note_#{existing_note.id} .original-note-content", count: 1, visible: false)
update_note(existing_note, updated_text)
expect(page).to have_selector("#note_#{existing_note.id}", text: updated_text)
expect(page).to have_selector("#note_#{existing_note.id} .original-note-content", count: 1, visible: false)
end
it 'displays the updated content' do it 'displays the updated content' do
expect(page).to have_selector("#note_#{existing_note.id}", text: note_text) expect(page).to have_selector("#note_#{existing_note.id}", text: note_text)
...@@ -49,24 +39,14 @@ feature 'Issue notes polling', :js do ...@@ -49,24 +39,14 @@ feature 'Issue notes polling', :js do
expect(page).to have_selector("#note_#{existing_note.id}", text: updated_text) expect(page).to have_selector("#note_#{existing_note.id}", text: updated_text)
end end
it 'when editing but have not changed anything, and an update comes in, show the updated content in the textarea' do it 'when editing but have not changed anything, and an update comes in, show warning and does not update the note' do
click_edit_action(existing_note) click_edit_action(existing_note)
expect(page).to have_field("note[note]", with: note_text) expect(page).to have_field("note[note]", with: note_text)
update_note(existing_note, updated_text) update_note(existing_note, updated_text)
expect(page).to have_field("note[note]", with: updated_text) expect(page).not_to have_field("note[note]", with: updated_text)
end
it 'when editing but you changed some things, and an update comes in, show a warning' do
click_edit_action(existing_note)
expect(page).to have_field("note[note]", with: note_text)
find("#note_#{existing_note.id} .js-note-text").set('something random')
update_note(existing_note, updated_text)
expect(page).to have_selector(".alert") expect(page).to have_selector(".alert")
end end
...@@ -75,8 +55,6 @@ feature 'Issue notes polling', :js do ...@@ -75,8 +55,6 @@ feature 'Issue notes polling', :js do
expect(page).to have_field("note[note]", with: note_text) expect(page).to have_field("note[note]", with: note_text)
find("#note_#{existing_note.id} .js-note-text").set('something random')
update_note(existing_note, updated_text) update_note(existing_note, updated_text)
find("#note_#{existing_note.id} .note-edit-cancel").click find("#note_#{existing_note.id} .note-edit-cancel").click
...@@ -97,14 +75,12 @@ feature 'Issue notes polling', :js do ...@@ -97,14 +75,12 @@ feature 'Issue notes polling', :js do
visit project_issue_path(project, issue) visit project_issue_path(project, issue)
end end
it 'has .original-note-content to compare against' do it 'displays the updated content' do
expect(page).to have_selector("#note_#{existing_note.id}", text: note_text) expect(page).to have_selector("#note_#{existing_note.id}", text: note_text)
expect(page).to have_selector("#note_#{existing_note.id} .original-note-content", count: 1, visible: false)
update_note(existing_note, updated_text) update_note(existing_note, updated_text)
expect(page).to have_selector("#note_#{existing_note.id}", text: updated_text) expect(page).to have_selector("#note_#{existing_note.id}", text: updated_text)
expect(page).to have_selector("#note_#{existing_note.id} .original-note-content", count: 1, visible: false)
end end
end end
...@@ -118,16 +94,15 @@ feature 'Issue notes polling', :js do ...@@ -118,16 +94,15 @@ feature 'Issue notes polling', :js do
visit project_issue_path(project, issue) visit project_issue_path(project, issue)
end end
it 'has .original-note-content to compare against' do it 'shows the system note' do
expect(page).to have_selector("#note_#{system_note.id}", text: note_text) expect(page).to have_selector("#note_#{system_note.id}", text: note_text)
expect(page).to have_selector("#note_#{system_note.id} .original-note-content", count: 1, visible: false)
end end
end end
end end
def update_note(note, new_text) def update_note(note, new_text)
note.update(note: new_text) note.update(note: new_text)
page.execute_script('notes.refresh();') wait_for_requests
end end
def click_edit_action(note) def click_edit_action(note)
......
...@@ -11,10 +11,14 @@ feature 'Member autocomplete', :js do ...@@ -11,10 +11,14 @@ feature 'Member autocomplete', :js do
sign_in(user) sign_in(user)
end end
shared_examples "open suggestions when typing @" do shared_examples "open suggestions when typing @" do |resource_name|
before do before do
page.within('.new-note') do page.within('.new-note') do
find('#note_note').send_keys('@') if resource_name == 'issue'
find('#note-body').send_keys('@')
else
find('#note_note').send_keys('@')
end
end end
end end
...@@ -32,7 +36,7 @@ feature 'Member autocomplete', :js do ...@@ -32,7 +36,7 @@ feature 'Member autocomplete', :js do
visit project_issue_path(project, noteable) visit project_issue_path(project, noteable)
end end
include_examples "open suggestions when typing @" include_examples "open suggestions when typing @", 'issue'
end end
context 'adding a new note on a Merge Request' do context 'adding a new note on a Merge Request' do
...@@ -45,7 +49,7 @@ feature 'Member autocomplete', :js do ...@@ -45,7 +49,7 @@ feature 'Member autocomplete', :js do
visit project_merge_request_path(project, noteable) visit project_merge_request_path(project, noteable)
end end
include_examples "open suggestions when typing @" include_examples "open suggestions when typing @", 'merge_request'
end end
context 'adding a new note on a Commit' do context 'adding a new note on a Commit' do
...@@ -60,6 +64,6 @@ feature 'Member autocomplete', :js do ...@@ -60,6 +64,6 @@ feature 'Member autocomplete', :js do
visit project_commit_path(project, noteable) visit project_commit_path(project, noteable)
end end
include_examples "open suggestions when typing @" include_examples "open suggestions when typing @", 'commit'
end end
end end
...@@ -18,7 +18,7 @@ describe 'Reportable note on commit', :js do ...@@ -18,7 +18,7 @@ describe 'Reportable note on commit', :js do
visit project_commit_path(project, sample_commit.id) visit project_commit_path(project, sample_commit.id)
end end
it_behaves_like 'reportable note' it_behaves_like 'reportable note', 'commit'
end end
context 'a diff note' do context 'a diff note' do
...@@ -28,6 +28,6 @@ describe 'Reportable note on commit', :js do ...@@ -28,6 +28,6 @@ describe 'Reportable note on commit', :js do
visit project_commit_path(project, sample_commit.id) visit project_commit_path(project, sample_commit.id)
end end
it_behaves_like 'reportable note' it_behaves_like 'reportable note', 'commit'
end end
end end
...@@ -13,5 +13,5 @@ describe 'Reportable note on issue', :js do ...@@ -13,5 +13,5 @@ describe 'Reportable note on issue', :js do
visit project_issue_path(project, issue) visit project_issue_path(project, issue)
end end
it_behaves_like 'reportable note' it_behaves_like 'reportable note', 'issue'
end end
...@@ -15,12 +15,12 @@ describe 'Reportable note on merge request', :js do ...@@ -15,12 +15,12 @@ describe 'Reportable note on merge request', :js do
context 'a normal note' do context 'a normal note' do
let!(:note) { create(:note_on_merge_request, noteable: merge_request, project: project) } let!(:note) { create(:note_on_merge_request, noteable: merge_request, project: project) }
it_behaves_like 'reportable note' it_behaves_like 'reportable note', 'merge_request'
end end
context 'a diff note' do context 'a diff note' do
let!(:note) { create(:diff_note_on_merge_request, noteable: merge_request, project: project) } let!(:note) { create(:diff_note_on_merge_request, noteable: merge_request, project: project) }
it_behaves_like 'reportable note' it_behaves_like 'reportable note', 'merge_request'
end end
end end
...@@ -17,6 +17,6 @@ describe 'Reportable note on snippets', :js do ...@@ -17,6 +17,6 @@ describe 'Reportable note on snippets', :js do
visit project_snippet_path(project, snippet) visit project_snippet_path(project, snippet)
end end
it_behaves_like 'reportable note' it_behaves_like 'reportable note', 'snippet'
end end
end end
...@@ -181,7 +181,7 @@ feature 'Task Lists' do ...@@ -181,7 +181,7 @@ feature 'Task Lists' do
project: project, author: user) project: project, author: user)
end end
it 'renders for note body' do it 'renders for note body', :js do
visit_issue(project, issue) visit_issue(project, issue)
expect(page).to have_selector('.note ul.task-list', count: 1) expect(page).to have_selector('.note ul.task-list', count: 1)
...@@ -189,15 +189,14 @@ feature 'Task Lists' do ...@@ -189,15 +189,14 @@ feature 'Task Lists' do
expect(page).to have_selector('.note ul input[checked]', count: 2) expect(page).to have_selector('.note ul input[checked]', count: 2)
end end
it 'contains the required selectors' do it 'contains the required selectors', :js do
visit_issue(project, issue) visit_issue(project, issue)
expect(page).to have_selector('.note .js-task-list-container') expect(page).to have_selector('.note .js-task-list-container')
expect(page).to have_selector('.note .js-task-list-container .task-list .task-list-item .task-list-item-checkbox') expect(page).to have_selector('.note .js-task-list-container .task-list .task-list-item .task-list-item-checkbox')
expect(page).to have_selector('.note .js-task-list-container .js-task-list-field')
end end
it 'is only editable by author' do it 'is only editable by author', :js do
visit_issue(project, issue) visit_issue(project, issue)
expect(page).to have_selector('.js-task-list-container') expect(page).to have_selector('.js-task-list-container')
...@@ -215,7 +214,7 @@ feature 'Task Lists' do ...@@ -215,7 +214,7 @@ feature 'Task Lists' do
project: project, author: user) project: project, author: user)
end end
it 'renders for note body' do it 'renders for note body', :js do
visit_issue(project, issue) visit_issue(project, issue)
expect(page).to have_selector('.note ul.task-list', count: 1) expect(page).to have_selector('.note ul.task-list', count: 1)
...@@ -230,7 +229,7 @@ feature 'Task Lists' do ...@@ -230,7 +229,7 @@ feature 'Task Lists' do
project: project, author: user) project: project, author: user)
end end
it 'renders for note body' do it 'renders for note body', :js do
visit_issue(project, issue) visit_issue(project, issue)
expect(page).to have_selector('.note ul.task-list', count: 1) expect(page).to have_selector('.note ul.task-list', count: 1)
......
...@@ -10,6 +10,7 @@ feature 'User uploads file to note' do ...@@ -10,6 +10,7 @@ feature 'User uploads file to note' do
before do before do
sign_in(user) sign_in(user)
visit project_issue_path(project, issue) visit project_issue_path(project, issue)
wait_for_requests
end end
context 'before uploading' do context 'before uploading' do
......
...@@ -18,6 +18,8 @@ ...@@ -18,6 +18,8 @@
"total_time_spent": { "type": "integer" }, "total_time_spent": { "type": "integer" },
"human_time_estimate": { "type": ["integer", "null"] }, "human_time_estimate": { "type": ["integer", "null"] },
"human_total_time_spent": { "type": ["integer", "null"] }, "human_total_time_spent": { "type": ["integer", "null"] },
"milestone": { "type": ["object", "null"] },
"labels": { "type": ["array", "null"] },
"in_progress_merge_commit_sha": { "type": ["string", "null"] }, "in_progress_merge_commit_sha": { "type": ["string", "null"] },
"merge_error": { "type": ["string", "null"] }, "merge_error": { "type": ["string", "null"] },
"merge_commit_sha": { "type": ["string", "null"] }, "merge_commit_sha": { "type": ["string", "null"] },
......
...@@ -25,9 +25,10 @@ import '~/lib/utils/common_utils'; ...@@ -25,9 +25,10 @@ import '~/lib/utils/common_utils';
}; };
describe('AwardsHandler', function() { describe('AwardsHandler', function() {
preloadFixtures('issues/issue_with_comment.html.raw'); preloadFixtures('merge_requests/diff_comment.html.raw');
beforeEach(function(done) { beforeEach(function(done) {
loadFixtures('issues/issue_with_comment.html.raw'); loadFixtures('merge_requests/diff_comment.html.raw');
$('body').data('page', 'projects:merge_requests:show');
loadAwardsHandler(true).then((obj) => { loadAwardsHandler(true).then((obj) => {
awardsHandler = obj; awardsHandler = obj;
spyOn(awardsHandler, 'postEmoji').and.callFake((button, url, emoji, cb) => cb()); spyOn(awardsHandler, 'postEmoji').and.callFake((button, url, emoji, cb) => cb());
...@@ -139,7 +140,7 @@ import '~/lib/utils/common_utils'; ...@@ -139,7 +140,7 @@ import '~/lib/utils/common_utils';
}); });
describe('::getAwardUrl', function() { describe('::getAwardUrl', function() {
return it('returns the url for request', function() { return it('returns the url for request', function() {
return expect(awardsHandler.getAwardUrl()).toBe('http://test.host/frontend-fixtures/issues-project/issues/1/toggle_award_emoji'); return expect(awardsHandler.getAwardUrl()).toBe('http://test.host/frontend-fixtures/merge-requests-project/merge_requests/1/toggle_award_emoji');
}); });
}); });
describe('::addAward and ::checkMutuality', function() { describe('::addAward and ::checkMutuality', function() {
......
/* eslint-disable space-before-function-paren, no-var, no-return-assign, comma-dangle, jasmine/no-spec-dupes, new-cap, max-len */
import '~/behaviors/quick_submit'; import '~/behaviors/quick_submit';
(function() { describe('Quick Submit behavior', () => {
describe('Quick Submit behavior', function() { const keydownEvent = (options = { keyCode: 13, metaKey: true }) => $.Event('keydown', options);
var keydownEvent;
preloadFixtures('issues/open-issue.html.raw');
beforeEach(function() {
loadFixtures('issues/open-issue.html.raw');
$('form').submit(function(e) {
// Prevent a form submit from moving us off the testing page
return e.preventDefault();
});
this.spies = {
submit: spyOnEvent('form', 'submit')
};
this.textarea = $('.js-quick-submit textarea').first(); preloadFixtures('merge_requests/merge_request_with_task_list.html.raw');
});
it('does not respond to other keyCodes', function() {
this.textarea.trigger(keydownEvent({
keyCode: 32
}));
return expect(this.spies.submit).not.toHaveBeenTriggered();
});
it('does not respond to Enter alone', function() {
this.textarea.trigger(keydownEvent({
ctrlKey: false,
metaKey: false
}));
return expect(this.spies.submit).not.toHaveBeenTriggered();
});
it('does not respond to repeated events', function() {
this.textarea.trigger(keydownEvent({
repeat: true
}));
return expect(this.spies.submit).not.toHaveBeenTriggered();
});
it('disables input of type submit', function() {
const submitButton = $('.js-quick-submit input[type=submit]');
this.textarea.trigger(keydownEvent());
expect(submitButton).toBeDisabled(); beforeEach(() => {
loadFixtures('merge_requests/merge_request_with_task_list.html.raw');
$('body').attr('data-page', 'projects:merge_requests:show');
$('form').submit((e) => {
// Prevent a form submit from moving us off the testing page
e.preventDefault();
}); });
it('disables button of type submit', function() { this.spies = {
const submitButton = $('.js-quick-submit input[type=submit]'); submit: spyOnEvent('form', 'submit'),
this.textarea.trigger(keydownEvent()); };
expect(submitButton).toBeDisabled(); this.textarea = $('.js-quick-submit textarea').first();
}); });
it('only clicks one submit', function() {
const existingSubmit = $('.js-quick-submit input[type=submit]');
// Add an extra submit button
const newSubmit = $('<button type="submit">Submit it</button>');
newSubmit.insertAfter(this.textarea);
const oldClick = spyOnEvent(existingSubmit, 'click'); it('does not respond to other keyCodes', () => {
const newClick = spyOnEvent(newSubmit, 'click'); this.textarea.trigger(keydownEvent({
keyCode: 32,
}));
expect(this.spies.submit).not.toHaveBeenTriggered();
});
this.textarea.trigger(keydownEvent()); it('does not respond to Enter alone', () => {
this.textarea.trigger(keydownEvent({
ctrlKey: false,
metaKey: false,
}));
expect(this.spies.submit).not.toHaveBeenTriggered();
});
expect(oldClick).not.toHaveBeenTriggered(); it('does not respond to repeated events', () => {
expect(newClick).toHaveBeenTriggered(); this.textarea.trigger(keydownEvent({
}); repeat: true,
// We cannot stub `navigator.userAgent` for CI's `rake karma` task, so we'll }));
// only run the tests that apply to the current platform expect(this.spies.submit).not.toHaveBeenTriggered();
if (navigator.userAgent.match(/Macintosh/)) { });
it('responds to Meta+Enter', function() {
this.textarea.trigger(keydownEvent()); it('disables input of type submit', () => {
return expect(this.spies.submit).toHaveBeenTriggered(); const submitButton = $('.js-quick-submit input[type=submit]');
}); this.textarea.trigger(keydownEvent());
it('excludes other modifier keys', function() {
this.textarea.trigger(keydownEvent({ expect(submitButton).toBeDisabled();
altKey: true });
})); it('disables button of type submit', () => {
this.textarea.trigger(keydownEvent({ const submitButton = $('.js-quick-submit input[type=submit]');
ctrlKey: true this.textarea.trigger(keydownEvent());
}));
this.textarea.trigger(keydownEvent({ expect(submitButton).toBeDisabled();
shiftKey: true });
})); it('only clicks one submit', () => {
return expect(this.spies.submit).not.toHaveBeenTriggered(); const existingSubmit = $('.js-quick-submit input[type=submit]');
}); // Add an extra submit button
} else { const newSubmit = $('<button type="submit">Submit it</button>');
it('responds to Ctrl+Enter', function() { newSubmit.insertAfter(this.textarea);
const oldClick = spyOnEvent(existingSubmit, 'click');
const newClick = spyOnEvent(newSubmit, 'click');
this.textarea.trigger(keydownEvent());
expect(oldClick).not.toHaveBeenTriggered();
expect(newClick).toHaveBeenTriggered();
});
// We cannot stub `navigator.userAgent` for CI's `rake karma` task, so we'll
// only run the tests that apply to the current platform
if (navigator.userAgent.match(/Macintosh/)) {
describe('In Macintosh', () => {
it('responds to Meta+Enter', () => {
this.textarea.trigger(keydownEvent()); this.textarea.trigger(keydownEvent());
return expect(this.spies.submit).toHaveBeenTriggered(); return expect(this.spies.submit).toHaveBeenTriggered();
}); });
it('excludes other modifier keys', function() {
it('excludes other modifier keys', () => {
this.textarea.trigger(keydownEvent({ this.textarea.trigger(keydownEvent({
altKey: true altKey: true,
})); }));
this.textarea.trigger(keydownEvent({ this.textarea.trigger(keydownEvent({
metaKey: true ctrlKey: true,
})); }));
this.textarea.trigger(keydownEvent({ this.textarea.trigger(keydownEvent({
shiftKey: true shiftKey: true,
})); }));
return expect(this.spies.submit).not.toHaveBeenTriggered(); return expect(this.spies.submit).not.toHaveBeenTriggered();
}); });
} });
return keydownEvent = function(options) { } else {
var defaults; it('responds to Ctrl+Enter', () => {
if (navigator.userAgent.match(/Macintosh/)) { this.textarea.trigger(keydownEvent());
defaults = { return expect(this.spies.submit).toHaveBeenTriggered();
keyCode: 13, });
metaKey: true
}; it('excludes other modifier keys', () => {
} else { this.textarea.trigger(keydownEvent({
defaults = { altKey: true,
keyCode: 13, }));
ctrlKey: true this.textarea.trigger(keydownEvent({
}; metaKey: true,
} }));
return $.Event('keydown', $.extend({}, defaults, options)); this.textarea.trigger(keydownEvent({
}; shiftKey: true,
}); }));
}).call(window); return expect(this.spies.submit).not.toHaveBeenTriggered();
});
}
});
...@@ -55,6 +55,11 @@ describe Projects::MergeRequestsController, '(JavaScript fixtures)', type: :cont ...@@ -55,6 +55,11 @@ describe Projects::MergeRequestsController, '(JavaScript fixtures)', type: :cont
render_merge_request(example.description, merge_request) render_merge_request(example.description, merge_request)
end end
it 'merge_requests/merge_request_with_comment.html.raw' do |example|
create(:note_on_merge_request, author: admin, project: project, noteable: merge_request, note: '- [ ] Task List Item')
render_merge_request(example.description, merge_request)
end
private private
def render_merge_request(fixture_file_name, merge_request) def render_merge_request(fixture_file_name, merge_request)
......
...@@ -41,9 +41,9 @@ describe('Issuable output', () => { ...@@ -41,9 +41,9 @@ describe('Issuable output', () => {
initialTitleText: '', initialTitleText: '',
initialDescriptionHtml: '', initialDescriptionHtml: '',
initialDescriptionText: '', initialDescriptionText: '',
markdownPreviewUrl: '/', markdownPreviewPath: '/',
markdownDocs: '/', markdownDocsPath: '/',
projectsAutocompleteUrl: '/', projectsAutocompletePath: '/',
isConfidential: false, isConfidential: false,
projectNamespace: '/', projectNamespace: '/',
projectPath: '/', projectPath: '/',
......
...@@ -25,8 +25,8 @@ describe('Description field component', () => { ...@@ -25,8 +25,8 @@ describe('Description field component', () => {
vm = new Component({ vm = new Component({
el, el,
propsData: { propsData: {
markdownPreviewUrl: '/', markdownPreviewPath: '/',
markdownDocs: '/', markdownDocsPath: '/',
formState: store.formState, formState: store.formState,
}, },
}).$mount(); }).$mount();
......
...@@ -15,7 +15,7 @@ describe('Project move field component', () => { ...@@ -15,7 +15,7 @@ describe('Project move field component', () => {
vm = new Component({ vm = new Component({
propsData: { propsData: {
formState, formState,
projectsAutocompleteUrl: '/autocomplete', projectsAutocompletePath: '/autocomplete',
}, },
}).$mount(); }).$mount();
......
...@@ -18,9 +18,9 @@ describe('Inline edit form component', () => { ...@@ -18,9 +18,9 @@ describe('Inline edit form component', () => {
description: 'a', description: 'a',
lockedWarningVisible: false, lockedWarningVisible: false,
}, },
markdownPreviewUrl: '/', markdownPreviewPath: '/',
markdownDocs: '/', markdownDocsPath: '/',
projectsAutocompleteUrl: '/', projectsAutocompletePath: '/',
projectPath: '/', projectPath: '/',
projectNamespace: '/', projectNamespace: '/',
}, },
......
import Vue from 'vue';
import store from '~/notes/stores';
import issueCommentForm from '~/notes/components/issue_comment_form.vue';
import { loggedOutIssueData, notesDataMock, userDataMock, issueDataMock } from '../mock_data';
import { keyboardDownEvent } from '../../issue_show/helpers';
describe('issue_comment_form component', () => {
let vm;
const Component = Vue.extend(issueCommentForm);
let mountComponent;
beforeEach(() => {
mountComponent = () => new Component({
store,
}).$mount();
});
afterEach(() => {
vm.$destroy();
});
describe('user is logged in', () => {
beforeEach(() => {
store.dispatch('setUserData', userDataMock);
store.dispatch('setIssueData', issueDataMock);
store.dispatch('setNotesData', notesDataMock);
vm = mountComponent();
});
it('should render user avatar with link', () => {
expect(vm.$el.querySelector('.timeline-icon .user-avatar-link').getAttribute('href')).toEqual(userDataMock.path);
});
describe('textarea', () => {
it('should render textarea with placeholder', () => {
expect(
vm.$el.querySelector('.js-main-target-form textarea').getAttribute('placeholder'),
).toEqual('Write a comment or drag your files here...');
});
it('should support quick actions', () => {
expect(
vm.$el.querySelector('.js-main-target-form textarea').getAttribute('data-supports-quick-actions'),
).toEqual('true');
});
it('should link to markdown docs', () => {
const { markdownDocsPath } = notesDataMock;
expect(vm.$el.querySelector(`a[href="${markdownDocsPath}"]`).textContent.trim()).toEqual('Markdown');
});
it('should link to quick actions docs', () => {
const { quickActionsDocsPath } = notesDataMock;
expect(vm.$el.querySelector(`a[href="${quickActionsDocsPath}"]`).textContent.trim()).toEqual('quick actions');
});
describe('edit mode', () => {
it('should enter edit mode when arrow up is pressed', () => {
spyOn(vm, 'editCurrentUserLastNote').and.callThrough();
vm.$el.querySelector('.js-main-target-form textarea').value = 'Foo';
vm.$el.querySelector('.js-main-target-form textarea').dispatchEvent(keyboardDownEvent(38, true));
expect(vm.editCurrentUserLastNote).toHaveBeenCalled();
});
});
describe('event enter', () => {
it('should save note when cmd/ctrl+enter is pressed', () => {
spyOn(vm, 'handleSave').and.callThrough();
vm.$el.querySelector('.js-main-target-form textarea').value = 'Foo';
vm.$el.querySelector('.js-main-target-form textarea').dispatchEvent(keyboardDownEvent(13, true));
expect(vm.handleSave).toHaveBeenCalled();
});
});
});
describe('actions', () => {
it('should be possible to close the issue', () => {
expect(vm.$el.querySelector('.btn-comment-and-close').textContent.trim()).toEqual('Close issue');
});
it('should render comment button as disabled', () => {
expect(vm.$el.querySelector('.js-comment-submit-button').getAttribute('disabled')).toEqual('disabled');
});
it('should enable comment button if it has note', (done) => {
vm.note = 'Foo';
Vue.nextTick(() => {
expect(vm.$el.querySelector('.js-comment-submit-button').getAttribute('disabled')).toEqual(null);
done();
});
});
it('should update buttons texts when it has note', (done) => {
vm.note = 'Foo';
Vue.nextTick(() => {
expect(vm.$el.querySelector('.btn-comment-and-close').textContent.trim()).toEqual('Comment & close issue');
expect(vm.$el.querySelector('.js-note-discard')).toBeDefined();
done();
});
});
});
describe('issue is confidential', () => {
it('shows information warning', (done) => {
store.dispatch('setIssueData', Object.assign(issueDataMock, { confidential: true }));
Vue.nextTick(() => {
expect(vm.$el.querySelector('.confidential-issue-warning')).toBeDefined();
done();
});
});
});
});
describe('user is not logged in', () => {
beforeEach(() => {
store.dispatch('setUserData', null);
store.dispatch('setIssueData', loggedOutIssueData);
store.dispatch('setNotesData', notesDataMock);
vm = mountComponent();
});
it('should render signed out widget', () => {
expect(vm.$el.textContent.replace(/\s+/g, ' ').trim()).toEqual('Please register or sign in to reply');
});
it('should not render submission form', () => {
expect(vm.$el.querySelector('textarea')).toEqual(null);
});
});
});
import Vue from 'vue';
import store from '~/notes/stores';
import issueDiscussion from '~/notes/components/issue_discussion.vue';
import { issueDataMock, discussionMock, notesDataMock } from '../mock_data';
describe('issue_discussion component', () => {
let vm;
beforeEach(() => {
const Component = Vue.extend(issueDiscussion);
store.dispatch('setIssueData', issueDataMock);
store.dispatch('setNotesData', notesDataMock);
vm = new Component({
store,
propsData: {
note: discussionMock,
},
}).$mount();
});
afterEach(() => {
vm.$destroy();
});
it('should render user avatar', () => {
expect(vm.$el.querySelector('.user-avatar-link')).toBeDefined();
});
it('should render discussion header', () => {
expect(vm.$el.querySelector('.discussion-header')).toBeDefined();
expect(vm.$el.querySelectorAll('.notes li').length).toEqual(discussionMock.notes.length);
});
describe('actions', () => {
it('should render reply button', () => {
expect(vm.$el.querySelector('.js-vue-discussion-reply').textContent.trim()).toEqual('Reply...');
});
it('should toggle reply form', (done) => {
vm.$el.querySelector('.js-vue-discussion-reply').click();
Vue.nextTick(() => {
expect(vm.$refs.noteForm).toBeDefined();
expect(vm.isReplying).toEqual(true);
done();
});
});
});
});
import Vue from 'vue';
import store from '~/notes/stores';
import issueActions from '~/notes/components/issue_note_actions.vue';
import { userDataMock } from '../mock_data';
describe('issse_note_actions component', () => {
let vm;
let Component;
beforeEach(() => {
Component = Vue.extend(issueActions);
});
afterEach(() => {
vm.$destroy();
});
describe('user is logged in', () => {
let props;
beforeEach(() => {
props = {
accessLevel: 'Master',
authorId: 26,
canDelete: true,
canEdit: true,
canReportAsAbuse: true,
noteId: 539,
reportAbusePath: '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F7%23note_539&user_id=26',
};
store.dispatch('setUserData', userDataMock);
vm = new Component({
store,
propsData: props,
}).$mount();
});
it('should render access level badge', () => {
expect(vm.$el.querySelector('.note-role').textContent.trim()).toEqual(props.accessLevel);
});
it('should render emoji link', () => {
expect(vm.$el.querySelector('.js-add-award')).toBeDefined();
});
describe('actions dropdown', () => {
it('should be possible to edit the comment', () => {
expect(vm.$el.querySelector('.js-note-edit')).toBeDefined();
});
it('should be possible to report as abuse', () => {
expect(vm.$el.querySelector(`a[href="${props.reportAbusePath}"]`)).toBeDefined();
});
it('should be possible to delete comment', () => {
expect(vm.$el.querySelector('.js-note-delete')).toBeDefined();
});
});
});
describe('user is not logged in', () => {
let props;
beforeEach(() => {
store.dispatch('setUserData', {});
props = {
accessLevel: 'Master',
authorId: 26,
canDelete: false,
canEdit: false,
canReportAsAbuse: false,
noteId: 539,
reportAbusePath: '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F7%23note_539&user_id=26',
};
vm = new Component({
store,
propsData: props,
}).$mount();
});
it('should not render emoji link', () => {
expect(vm.$el.querySelector('.js-add-award')).toEqual(null);
});
it('should not render actions dropdown', () => {
expect(vm.$el.querySelector('.more-actions')).toEqual(null);
});
});
});
import Vue from 'vue';
import issueNotesApp from '~/notes/components/issue_notes_app.vue';
import service from '~/notes/services/issue_notes_service';
import * as mockData from '../mock_data';
describe('issue_note_app', () => {
let mountComponent;
let vm;
const individualNoteInterceptor = (request, next) => {
next(request.respondWith(JSON.stringify(mockData.individualNoteServerResponse), {
status: 200,
}));
};
const discussionNoteInterceptor = (request, next) => {
next(request.respondWith(JSON.stringify(mockData.discussionNoteServerResponse), {
status: 200,
}));
};
beforeEach(() => {
const IssueNotesApp = Vue.extend(issueNotesApp);
mountComponent = (data) => {
const props = data || {
issueData: mockData.issueDataMock,
notesData: mockData.notesDataMock,
userData: mockData.userDataMock,
};
return new IssueNotesApp({
propsData: props,
}).$mount();
};
});
afterEach(() => {
vm.$destroy();
});
describe('set data', () => {
const responseInterceptor = (request, next) => {
next(request.respondWith(JSON.stringify([]), {
status: 200,
}));
};
beforeEach(() => {
Vue.http.interceptors.push(responseInterceptor);
vm = mountComponent();
});
afterEach(() => {
Vue.http.interceptors = _.without(Vue.http.interceptors, responseInterceptor);
});
it('should set notes data', () => {
expect(vm.$store.state.notesData).toEqual(mockData.notesDataMock);
});
it('should set issue data', () => {
expect(vm.$store.state.issueData).toEqual(mockData.issueDataMock);
});
it('should set user data', () => {
expect(vm.$store.state.userData).toEqual(mockData.userDataMock);
});
it('should fetch notes', () => {
expect(vm.$store.state.notes).toEqual([]);
});
});
describe('render', () => {
beforeEach(() => {
Vue.http.interceptors.push(individualNoteInterceptor);
vm = mountComponent();
});
afterEach(() => {
Vue.http.interceptors = _.without(Vue.http.interceptors, individualNoteInterceptor);
});
it('should render list of notes', (done) => {
const note = mockData.individualNoteServerResponse[0].notes[0];
setTimeout(() => {
expect(
vm.$el.querySelector('.main-notes-list .note-header-author-name').textContent.trim(),
).toEqual(note.author.name);
expect(vm.$el.querySelector('.main-notes-list .note-text').innerHTML).toEqual(note.note_html);
done();
}, 0);
});
it('should render form', () => {
expect(vm.$el.querySelector('.js-main-target-form').tagName).toEqual('FORM');
expect(
vm.$el.querySelector('.js-main-target-form textarea').getAttribute('placeholder'),
).toEqual('Write a comment or drag your files here...');
});
it('should render form comment button as disabled', () => {
expect(
vm.$el.querySelector('.js-note-new-discussion').getAttribute('disabled'),
).toEqual('disabled');
});
});
describe('while fetching data', () => {
beforeEach(() => {
vm = mountComponent();
});
it('should render loading icon', () => {
expect(vm.$el.querySelector('.js-loading')).toBeDefined();
});
it('should render form', () => {
expect(vm.$el.querySelector('.js-main-target-form').tagName).toEqual('FORM');
expect(
vm.$el.querySelector('.js-main-target-form textarea').getAttribute('placeholder'),
).toEqual('Write a comment or drag your files here...');
});
});
describe('update note', () => {
describe('individual note', () => {
beforeEach(() => {
Vue.http.interceptors.push(individualNoteInterceptor);
spyOn(service, 'updateNote').and.callFake(() => Promise.resolve());
vm = mountComponent();
});
afterEach(() => {
Vue.http.interceptors = _.without(Vue.http.interceptors, individualNoteInterceptor);
});
it('renders edit form', (done) => {
setTimeout(() => {
vm.$el.querySelector('.js-note-edit').click();
Vue.nextTick(() => {
expect(vm.$el.querySelector('.js-vue-issue-note-form')).toBeDefined();
done();
});
}, 0);
});
it('calls the service to update the note', (done) => {
setTimeout(() => {
vm.$el.querySelector('.js-note-edit').click();
Vue.nextTick(() => {
vm.$el.querySelector('.js-vue-issue-note-form').value = 'this is a note';
vm.$el.querySelector('.js-vue-issue-save').click();
expect(service.updateNote).toHaveBeenCalled();
done();
});
}, 0);
});
});
describe('dicussion note', () => {
beforeEach(() => {
Vue.http.interceptors.push(discussionNoteInterceptor);
spyOn(service, 'updateNote').and.callFake(() => Promise.resolve());
vm = mountComponent();
});
afterEach(() => {
Vue.http.interceptors = _.without(Vue.http.interceptors, discussionNoteInterceptor);
});
it('renders edit form', (done) => {
setTimeout(() => {
vm.$el.querySelector('.js-note-edit').click();
Vue.nextTick(() => {
expect(vm.$el.querySelector('.js-vue-issue-note-form')).toBeDefined();
done();
});
}, 0);
});
it('updates the note and resets the edit form', (done) => {
setTimeout(() => {
vm.$el.querySelector('.js-note-edit').click();
Vue.nextTick(() => {
vm.$el.querySelector('.js-vue-issue-note-form').value = 'this is a note';
vm.$el.querySelector('.js-vue-issue-save').click();
expect(service.updateNote).toHaveBeenCalled();
done();
});
}, 0);
});
});
});
describe('new note form', () => {
beforeEach(() => {
vm = mountComponent();
});
it('should render markdown docs url', () => {
const { markdownDocsPath } = mockData.notesDataMock;
expect(vm.$el.querySelector(`a[href="${markdownDocsPath}"]`).textContent.trim()).toEqual('Markdown');
});
it('should render quick action docs url', () => {
const { quickActionsDocsPath } = mockData.notesDataMock;
expect(vm.$el.querySelector(`a[href="${quickActionsDocsPath}"]`).textContent.trim()).toEqual('quick actions');
});
});
describe('edit form', () => {
beforeEach(() => {
Vue.http.interceptors.push(individualNoteInterceptor);
vm = mountComponent();
});
afterEach(() => {
Vue.http.interceptors = _.without(Vue.http.interceptors, individualNoteInterceptor);
});
it('should render markdown docs url', (done) => {
setTimeout(() => {
vm.$el.querySelector('.js-note-edit').click();
const { markdownDocsPath } = mockData.notesDataMock;
Vue.nextTick(() => {
expect(
vm.$el.querySelector(`.edit-note a[href="${markdownDocsPath}"]`).textContent.trim(),
).toEqual('Markdown is supported');
done();
});
}, 0);
});
it('should not render quick actions docs url', (done) => {
setTimeout(() => {
vm.$el.querySelector('.js-note-edit').click();
const { quickActionsDocsPath } = mockData.notesDataMock;
Vue.nextTick(() => {
expect(
vm.$el.querySelector(`.edit-note a[href="${quickActionsDocsPath}"]`),
).toEqual(null);
done();
});
}, 0);
});
});
});
import Vue from 'vue';
import issueNoteAttachment from '~/notes/components/issue_note_attachment.vue';
describe('issue note attachment', () => {
it('should render properly', () => {
const props = {
attachment: {
filename: 'dk.png',
image: true,
url: '/dk.png',
},
};
const Component = Vue.extend(issueNoteAttachment);
const vm = new Component({
propsData: props,
}).$mount();
expect(vm.$el.classList.contains('note-attachment')).toBeTruthy();
expect(vm.$el.querySelector('img').src).toContain(props.attachment.url);
expect(vm.$el.querySelector('a').href).toContain(props.attachment.url);
});
});
import Vue from 'vue';
import store from '~/notes/stores';
import awardsNote from '~/notes/components/issue_note_awards_list.vue';
import { issueDataMock, notesDataMock } from '../mock_data';
describe('issue_note_awards_list component', () => {
let vm;
let awardsMock;
beforeEach(() => {
const Component = Vue.extend(awardsNote);
store.dispatch('setIssueData', issueDataMock);
store.dispatch('setNotesData', notesDataMock);
awardsMock = [
{
name: 'flag_tz',
user: { id: 1, name: 'Administrator', username: 'root' },
},
{
name: 'cartwheel_tone3',
user: { id: 12, name: 'Bobbie Stehr', username: 'erin' },
},
];
vm = new Component({
store,
propsData: {
awards: awardsMock,
noteAuthorId: 2,
noteId: 545,
toggleAwardPath: '/gitlab-org/gitlab-ce/notes/545/toggle_award_emoji',
},
}).$mount();
});
afterEach(() => {
vm.$destroy();
});
it('should render awarded emojis', () => {
expect(vm.$el.querySelector('.js-awards-block button [data-name="flag_tz"]')).toBeDefined();
expect(vm.$el.querySelector('.js-awards-block button [data-name="cartwheel_tone3"]')).toBeDefined();
});
it('should be possible to remove awareded emoji', () => {
spyOn(vm, 'handleAward').and.callThrough();
vm.$el.querySelector('.js-awards-block button').click();
expect(vm.handleAward).toHaveBeenCalledWith('flag_tz');
});
it('should be possible to add new emoji', () => {
expect(vm.$el.querySelector('.js-add-award')).toBeDefined();
});
});
import Vue from 'vue';
import store from '~/notes/stores';
import noteBody from '~/notes/components/issue_note_body.vue';
import { issueDataMock, notesDataMock, note } from '../mock_data';
describe('issue_note_body component', () => {
let vm;
beforeEach(() => {
const Component = Vue.extend(noteBody);
store.dispatch('setIssueData', issueDataMock);
store.dispatch('setNotesData', notesDataMock);
vm = new Component({
store,
propsData: {
note,
canEdit: true,
},
}).$mount();
});
afterEach(() => {
vm.$destroy();
});
it('should render the note', () => {
expect(vm.$el.querySelector('.note-text').innerHTML).toEqual(note.note_html);
});
it('should be render form if user is editing', (done) => {
vm.isEditing = true;
Vue.nextTick(() => {
expect(vm.$el.querySelector('textarea.js-task-list-field')).toBeDefined();
done();
});
});
it('should render awards list', () => {
expect(vm.$el.querySelector('.js-awards-block button [data-name="baseball"]')).toBeDefined();
expect(vm.$el.querySelector('.js-awards-block button [data-name="bath_tone3"]')).toBeDefined();
});
});
import Vue from 'vue';
import issueNoteEditedText from '~/notes/components/issue_note_edited_text.vue';
describe('issue_note_edited_text', () => {
let vm;
let props;
beforeEach(() => {
const Component = Vue.extend(issueNoteEditedText);
props = {
actionText: 'Edited',
className: 'foo-bar',
editedAt: '2017-08-04T09:52:31.062Z',
editedBy: {
avatar_url: 'path',
id: 1,
name: 'Root',
path: '/root',
state: 'active',
username: 'root',
},
};
vm = new Component({
propsData: props,
}).$mount();
});
afterEach(() => {
vm.$destroy();
});
it('should render block with provided className', () => {
expect(vm.$el.className).toEqual(props.className);
});
it('should render provided actionText', () => {
expect(vm.$el.textContent).toContain(props.actionText);
});
it('should render provided user information', () => {
const authorLink = vm.$el.querySelector('.js-vue-author');
expect(authorLink.getAttribute('href')).toEqual(props.editedBy.path);
expect(authorLink.textContent.trim()).toEqual(props.editedBy.name);
});
});
import Vue from 'vue';
import store from '~/notes/stores';
import issueNoteForm from '~/notes/components/issue_note_form.vue';
import { issueDataMock, notesDataMock } from '../mock_data';
import { keyboardDownEvent } from '../../issue_show/helpers';
describe('issue_note_form component', () => {
let vm;
let props;
beforeEach(() => {
const Component = Vue.extend(issueNoteForm);
store.dispatch('setIssueData', issueDataMock);
store.dispatch('setNotesData', notesDataMock);
props = {
isEditing: false,
noteBody: 'Magni suscipit eius consectetur enim et ex et commodi.',
noteId: 545,
};
vm = new Component({
store,
propsData: props,
}).$mount();
});
afterEach(() => {
vm.$destroy();
});
describe('conflicts editing', () => {
it('should show conflict message if note changes outside the component', (done) => {
vm.isEditing = true;
vm.noteBody = 'Foo';
const message = 'This comment has changed since you started editing, please review the updated comment to ensure information is not lost.';
Vue.nextTick(() => {
expect(
vm.$el.querySelector('.js-conflict-edit-warning').textContent.replace(/\s+/g, ' ').trim(),
).toEqual(message);
done();
});
});
});
describe('form', () => {
it('should render text area with placeholder', () => {
expect(
vm.$el.querySelector('textarea').getAttribute('placeholder'),
).toEqual('Write a comment or drag your files here...');
});
it('should link to markdown docs', () => {
const { markdownDocsPath } = notesDataMock;
expect(vm.$el.querySelector(`a[href="${markdownDocsPath}"]`).textContent.trim()).toEqual('Markdown');
});
describe('keyboard events', () => {
describe('up', () => {
it('should ender edit mode', () => {
spyOn(vm, 'editMyLastNote').and.callThrough();
vm.$el.querySelector('textarea').value = 'Foo';
vm.$el.querySelector('textarea').dispatchEvent(keyboardDownEvent(38, true));
expect(vm.editMyLastNote).toHaveBeenCalled();
});
});
describe('enter', () => {
it('should submit note', () => {
spyOn(vm, 'handleUpdate').and.callThrough();
vm.$el.querySelector('textarea').value = 'Foo';
vm.$el.querySelector('textarea').dispatchEvent(keyboardDownEvent(13, true));
expect(vm.handleUpdate).toHaveBeenCalled();
});
});
});
describe('actions', () => {
it('should be possible to cancel', (done) => {
spyOn(vm, 'cancelHandler').and.callThrough();
vm.isEditing = true;
Vue.nextTick(() => {
vm.$el.querySelector('.note-edit-cancel').click();
Vue.nextTick(() => {
expect(vm.cancelHandler).toHaveBeenCalled();
done();
});
});
});
it('should be possible to update the note', (done) => {
vm.isEditing = true;
Vue.nextTick(() => {
vm.$el.querySelector('textarea').value = 'Foo';
vm.$el.querySelector('.js-vue-issue-save').click();
Vue.nextTick(() => {
expect(vm.isSubmitting).toEqual(true);
done();
});
});
});
});
});
});
import Vue from 'vue';
import issueNoteHeader from '~/notes/components/issue_note_header.vue';
import store from '~/notes/stores';
describe('issue_note_header component', () => {
let vm;
let Component;
beforeEach(() => {
Component = Vue.extend(issueNoteHeader);
});
afterEach(() => {
vm.$destroy();
});
describe('individual note', () => {
beforeEach(() => {
vm = new Component({
store,
propsData: {
actionText: 'commented',
actionTextHtml: '',
author: {
avatar_url: null,
id: 1,
name: 'Root',
path: '/root',
state: 'active',
username: 'root',
},
createdAt: '2017-08-02T10:51:58.559Z',
includeToggle: false,
noteId: 1394,
},
}).$mount();
});
it('should render user information', () => {
expect(
vm.$el.querySelector('.note-header-author-name').textContent.trim(),
).toEqual('Root');
expect(
vm.$el.querySelector('.note-header-info a').getAttribute('href'),
).toEqual('/root');
});
it('should render timestamp link', () => {
expect(vm.$el.querySelector('a[href="#note_1394"]')).toBeDefined();
});
});
describe('discussion', () => {
beforeEach(() => {
vm = new Component({
store,
propsData: {
actionText: 'started a discussion',
actionTextHtml: '',
author: {
avatar_url: null,
id: 1,
name: 'Root',
path: '/root',
state: 'active',
username: 'root',
},
createdAt: '2017-08-02T10:51:58.559Z',
includeToggle: true,
noteId: 1395,
},
}).$mount();
});
it('should render toggle button', () => {
expect(vm.$el.querySelector('.js-vue-toggle-button')).toBeDefined();
});
it('should toggle the disucssion icon', (done) => {
expect(
vm.$el.querySelector('.js-vue-toggle-button i').classList.contains('fa-chevron-up'),
).toEqual(true);
vm.$el.querySelector('.js-vue-toggle-button').click();
Vue.nextTick(() => {
expect(
vm.$el.querySelector('.js-vue-toggle-button i').classList.contains('fa-chevron-down'),
).toEqual(true);
done();
});
});
});
});
import Vue from 'vue';
import issueNoteSignedOut from '~/notes/components/issue_note_signed_out_widget.vue';
import store from '~/notes/stores';
import { notesDataMock } from '../mock_data';
describe('issue_note_signed_out_widget component', () => {
let vm;
beforeEach(() => {
const Component = Vue.extend(issueNoteSignedOut);
store.dispatch('setNotesData', notesDataMock);
vm = new Component({
store,
}).$mount();
});
afterEach(() => {
vm.$destroy();
});
it('should render sign in link provided in the store', () => {
expect(
vm.$el.querySelector(`a[href="${notesDataMock.newSessionPath}"]`).textContent,
).toEqual('sign in');
});
it('should render register link provided in the store', () => {
expect(
vm.$el.querySelector(`a[href="${notesDataMock.registerPath}"]`).textContent,
).toEqual('register');
});
it('should render information text', () => {
expect(vm.$el.textContent.replace(/\s+/g, ' ').trim()).toEqual('Please register or sign in to reply');
});
});
import Vue from 'vue';
import store from '~/notes/stores';
import issueNote from '~/notes/components/issue_note.vue';
import { issueDataMock, notesDataMock, note } from '../mock_data';
describe('issue_note', () => {
let vm;
beforeEach(() => {
const Component = Vue.extend(issueNote);
store.dispatch('setIssueData', issueDataMock);
store.dispatch('setNotesData', notesDataMock);
vm = new Component({
store,
propsData: {
note,
},
}).$mount();
});
afterEach(() => {
vm.$destroy();
});
it('should render user information', () => {
expect(vm.$el.querySelector('.user-avatar-link img').getAttribute('src')).toEqual(note.author.avatar_url);
});
it('should render note header content', () => {
expect(vm.$el.querySelector('.note-header .note-header-author-name').textContent.trim()).toEqual(note.author.name);
expect(vm.$el.querySelector('.note-header .note-headline-meta').textContent.trim()).toContain('commented');
});
it('should render note actions', () => {
expect(vm.$el.querySelector('.note-actions')).toBeDefined();
});
it('should render issue body', () => {
expect(vm.$el.querySelector('.note-text').innerHTML).toEqual(note.note_html);
});
});
import Vue from 'vue';
import issuePlaceholderNote from '~/notes/components/issue_placeholder_note.vue';
import store from '~/notes/stores';
import { userDataMock } from '../mock_data';
describe('issue placeholder system note component', () => {
let vm;
beforeEach(() => {
const Component = Vue.extend(issuePlaceholderNote);
store.dispatch('setUserData', userDataMock);
vm = new Component({
store,
propsData: { note: { body: 'Foo' } },
}).$mount();
});
afterEach(() => {
vm.$destroy();
});
describe('user information', () => {
it('should render user avatar with link', () => {
expect(vm.$el.querySelector('.user-avatar-link').getAttribute('href')).toEqual(userDataMock.path);
expect(vm.$el.querySelector('.user-avatar-link img').getAttribute('src')).toEqual(userDataMock.avatar_url);
});
});
describe('note content', () => {
it('should render note header information', () => {
expect(vm.$el.querySelector('.note-header-info a').getAttribute('href')).toEqual(userDataMock.path);
expect(vm.$el.querySelector('.note-header-info .note-headline-light').textContent.trim()).toEqual(`@${userDataMock.username}`);
});
it('should render note body', () => {
expect(vm.$el.querySelector('.note-text p').textContent.trim()).toEqual('Foo');
});
});
});
import Vue from 'vue';
import placeholderSystemNote from '~/notes/components/issue_placeholder_system_note.vue';
describe('issue placeholder system note component', () => {
let mountComponent;
beforeEach(() => {
const PlaceholderSystemNote = Vue.extend(placeholderSystemNote);
mountComponent = props => new PlaceholderSystemNote({
propsData: {
note: {
body: props,
},
},
}).$mount();
});
it('should render system note placeholder with plain text', () => {
const vm = mountComponent('This is a placeholder');
expect(vm.$el.tagName).toEqual('LI');
expect(vm.$el.querySelector('.timeline-content em').textContent.trim()).toEqual('This is a placeholder');
});
});
import Vue from 'vue';
import issueSystemNote from '~/notes/components/issue_system_note.vue';
import store from '~/notes/stores';
describe('issue system note', () => {
let vm;
let props;
beforeEach(() => {
props = {
note: {
id: 1424,
author: {
id: 1,
name: 'Root',
username: 'root',
state: 'active',
avatar_url: 'path',
path: '/root',
},
note_html: '<p dir="auto">closed</p>',
system_note_icon_name: 'icon_status_closed',
created_at: '2017-08-02T10:51:58.559Z',
},
};
store.dispatch('setTargetNoteHash', `note_${props.note.id}`);
const Component = Vue.extend(issueSystemNote);
vm = new Component({
store,
propsData: props,
}).$mount();
});
it('should render a list item with correct id', () => {
expect(vm.$el.getAttribute('id')).toEqual(`note_${props.note.id}`);
});
it('should render target class is note is target note', () => {
expect(vm.$el.classList).toContain('target');
});
it('should render svg icon', () => {
expect(vm.$el.querySelector('.timeline-icon svg')).toBeDefined();
});
it('should render note header component', () => {
expect(
vm.$el.querySelector('.system-note-message').innerHTML,
).toEqual(props.note.note_html);
});
});
/* eslint-disable */
export const notesDataMock = {
discussionsPath: '/gitlab-org/gitlab-ce/issues/26/discussions.json',
lastFetchedAt: '1501862675',
markdownDocsPath: '/help/user/markdown',
newSessionPath: '/users/sign_in?redirect_to_referer=yes',
notesPath: '/gitlab-org/gitlab-ce/noteable/issue/98/notes',
quickActionsDocsPath: '/help/user/project/quick_actions',
registerPath: '/users/sign_in?redirect_to_referer=yes#register-pane',
};
export const userDataMock = {
avatar_url: 'mock_path',
id: 1,
name: 'Root',
path: '/root',
state: 'active',
username: 'root',
};
export const issueDataMock = {
assignees: [],
author_id: 1,
branch_name: null,
confidential: false,
create_note_path: '/gitlab-org/gitlab-ce/notes?target_id=98&target_type=issue',
created_at: '2017-02-07T10:11:18.395Z',
current_user: {
can_create_note: true,
can_update: true,
},
deleted_at: null,
description: '',
due_date: null,
human_time_estimate: null,
human_total_time_spent: null,
id: 98,
iid: 26,
labels: [],
lock_version: null,
milestone: null,
milestone_id: null,
moved_to_id: null,
preview_note_path: '/gitlab-org/gitlab-ce/preview_markdown?quick_actions_target_id=98&quick_actions_target_type=Issue',
project_id: 2,
state: 'opened',
time_estimate: 0,
title: '14',
total_time_spent: 0,
updated_at: '2017-08-04T09:53:01.226Z',
updated_by_id: 1,
web_url: '/gitlab-org/gitlab-ce/issues/26',
};
export const lastFetchedAt = '1501862675';
export const individualNote = {
expanded: true,
id: '0fb4e0e3f9276e55ff32eb4195add694aece4edd',
individual_note: true,
notes: [{
id: 1390,
attachment: {
url: null,
filename: null,
image: false,
},
author: {
id: 1,
name: 'Root',
username: 'root',
state: 'active',
avatar_url: 'test',
path: '/root',
},
created_at: '2017-08-01T17: 09: 33.762Z',
updated_at: '2017-08-01T17: 09: 33.762Z',
system: false,
noteable_id: 98,
noteable_type: 'Issue',
type: null,
human_access: 'Owner',
note: 'sdfdsaf',
note_html: '<p dir=\'auto\'>sdfdsaf</p>',
current_user: { can_edit: true },
discussion_id: '0fb4e0e3f9276e55ff32eb4195add694aece4edd',
emoji_awardable: true,
award_emoji: [
{ name: 'baseball', user: { id: 1, name: 'Root', username: 'root' } },
{ name: 'art', user: { id: 1, name: 'Root', username: 'root' } },
],
toggle_award_path: '/gitlab-org/gitlab-ce/notes/1390/toggle_award_emoji',
report_abuse_path: '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1390&user_id=1',
path: '/gitlab-org/gitlab-ce/notes/1390',
}],
reply_id: '0fb4e0e3f9276e55ff32eb4195add694aece4edd',
};
export const note = {
"id": 546,
"attachment": {
"url": null,
"filename": null,
"image": false
},
"author": {
"id": 1,
"name": "Administrator",
"username": "root",
"state": "active",
"avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon",
"path": "/root"
},
"created_at": "2017-08-10T15:24:03.087Z",
"updated_at": "2017-08-10T15:24:03.087Z",
"system": false,
"noteable_id": 67,
"noteable_type": "Issue",
"noteable_iid": 7,
"type": null,
"human_access": "Owner",
"note": "Vel id placeat reprehenderit sit numquam.",
"note_html": "<p dir=\"auto\">Vel id placeat reprehenderit sit numquam.</p>",
"current_user": {
"can_edit": true
},
"discussion_id": "d3842a451b7f3d9a5dfce329515127b2d29a4cd0",
"emoji_awardable": true,
"award_emoji": [{
"name": "baseball",
"user": {
"id": 1,
"name": "Administrator",
"username": "root"
}
}, {
"name": "bath_tone3",
"user": {
"id": 1,
"name": "Administrator",
"username": "root"
}
}],
"toggle_award_path": "/gitlab-org/gitlab-ce/notes/546/toggle_award_emoji",
"report_abuse_path": "/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F7%23note_546&user_id=1",
"path": "/gitlab-org/gitlab-ce/notes/546"
}
export const discussionMock = {
id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1',
reply_id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1',
expanded: true,
notes: [{
id: 1395,
attachment: {
url: null,
filename: null,
image: false,
},
author: {
id: 1,
name: 'Root',
username: 'root',
state: 'active',
avatar_url: null,
path: '/root',
},
created_at: '2017-08-02T10:51:58.559Z',
updated_at: '2017-08-02T10:51:58.559Z',
system: false,
noteable_id: 98,
noteable_type: 'Issue',
type: 'DiscussionNote',
human_access: 'Owner',
note: 'THIS IS A DICUSSSION!',
note_html: '<p dir=\'auto\'>THIS IS A DICUSSSION!</p>',
current_user: {
can_edit: true,
},
discussion_id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1',
emoji_awardable: true,
award_emoji: [],
toggle_award_path: '/gitlab-org/gitlab-ce/notes/1395/toggle_award_emoji',
report_abuse_path: '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1395&user_id=1',
path: '/gitlab-org/gitlab-ce/notes/1395',
}, {
id: 1396,
attachment: {
url: null,
filename: null,
image: false,
},
author: {
id: 1,
name: 'Root',
username: 'root',
state: 'active',
avatar_url: null,
path: '/root',
},
created_at: '2017-08-02T10:56:50.980Z',
updated_at: '2017-08-03T14:19:35.691Z',
system: false,
noteable_id: 98,
noteable_type: 'Issue',
type: 'DiscussionNote',
human_access: 'Owner',
note: 'sadfasdsdgdsf',
note_html: '<p dir=\'auto\'>sadfasdsdgdsf</p>',
last_edited_at: '2017-08-03T14:19:35.691Z',
last_edited_by: {
id: 1,
name: 'Root',
username: 'root',
state: 'active',
avatar_url: null,
path: '/root',
},
current_user: {
can_edit: true,
},
discussion_id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1',
emoji_awardable: true,
award_emoji: [],
toggle_award_path: '/gitlab-org/gitlab-ce/notes/1396/toggle_award_emoji',
report_abuse_path: '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1396&user_id=1',
path: '/gitlab-org/gitlab-ce/notes/1396',
}, {
id: 1437,
attachment: {
url: null,
filename: null,
image: false,
},
author: {
id: 1,
name: 'Root',
username: 'root',
state: 'active',
avatar_url: null,
path: '/root',
},
created_at: '2017-08-03T18:11:18.780Z',
updated_at: '2017-08-04T09:52:31.062Z',
system: false,
noteable_id: 98,
noteable_type: 'Issue',
type: 'DiscussionNote',
human_access: 'Owner',
note: 'adsfasf Should disappear',
note_html: '<p dir=\'auto\'>adsfasf Should disappear</p>',
last_edited_at: '2017-08-04T09:52:31.062Z',
last_edited_by: {
id: 1,
name: 'Root',
username: 'root',
state: 'active',
avatar_url: null,
path: '/root',
},
current_user: {
can_edit: true,
},
discussion_id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1',
emoji_awardable: true,
award_emoji: [],
toggle_award_path: '/gitlab-org/gitlab-ce/notes/1437/toggle_award_emoji',
report_abuse_path: '/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1437&user_id=1',
path: '/gitlab-org/gitlab-ce/notes/1437',
}],
individual_note: false,
};
export const loggedOutIssueData = {
"id": 98,
"iid": 26,
"author_id": 1,
"description": "",
"lock_version": 1,
"milestone_id": null,
"state": "opened",
"title": "asdsa",
"updated_by_id": 1,
"created_at": "2017-02-07T10:11:18.395Z",
"updated_at": "2017-08-08T10:22:51.564Z",
"deleted_at": null,
"time_estimate": 0,
"total_time_spent": 0,
"human_time_estimate": null,
"human_total_time_spent": null,
"milestone": null,
"labels": [],
"branch_name": null,
"confidential": false,
"assignees": [{
"id": 1,
"name": "Root",
"username": "root",
"state": "active",
"avatar_url": null,
"web_url": "http://localhost:3000/root"
}],
"due_date": null,
"moved_to_id": null,
"project_id": 2,
"web_url": "/gitlab-org/gitlab-ce/issues/26",
"current_user": {
"can_create_note": false,
"can_update": false
},
"create_note_path": "/gitlab-org/gitlab-ce/notes?target_id=98&target_type=issue",
"preview_note_path": "/gitlab-org/gitlab-ce/preview_markdown?quick_actions_target_id=98&quick_actions_target_type=Issue"
}
export const individualNoteServerResponse = [{
"id": "0fb4e0e3f9276e55ff32eb4195add694aece4edd",
"reply_id": "0fb4e0e3f9276e55ff32eb4195add694aece4edd",
"expanded": true,
"notes": [{
"id": 1390,
"attachment": {
"url": null,
"filename": null,
"image": false
},
"author": {
"id": 1,
"name": "Root",
"username": "root",
"state": "active",
"avatar_url": null,
"path": "/root"
},
"created_at": "2017-08-01T17:09:33.762Z",
"updated_at": "2017-08-01T17:09:33.762Z",
"system": false,
"noteable_id": 98,
"noteable_type": "Issue",
"type": null,
"human_access": "Owner",
"note": "sdfdsaf",
"note_html": "\u003cp dir=\"auto\"\u003esdfdsaf\u003c/p\u003e",
"current_user": {
"can_edit": true
},
"discussion_id": "0fb4e0e3f9276e55ff32eb4195add694aece4edd",
"emoji_awardable": true,
"award_emoji": [{
"name": "baseball",
"user": {
"id": 1,
"name": "Root",
"username": "root"
}
}, {
"name": "art",
"user": {
"id": 1,
"name": "Root",
"username": "root"
}
}],
"toggle_award_path": "/gitlab-org/gitlab-ce/notes/1390/toggle_award_emoji",
"report_abuse_path": "/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1390\u0026user_id=1",
"path": "/gitlab-org/gitlab-ce/notes/1390"
}],
"individual_note": true
}, {
"id": "70d5c92a4039a36c70100c6691c18c27e4b0a790",
"reply_id": "70d5c92a4039a36c70100c6691c18c27e4b0a790",
"expanded": true,
"notes": [{
"id": 1391,
"attachment": {
"url": null,
"filename": null,
"image": false
},
"author": {
"id": 1,
"name": "Root",
"username": "root",
"state": "active",
"avatar_url": null,
"path": "/root"
},
"created_at": "2017-08-02T10:51:38.685Z",
"updated_at": "2017-08-02T10:51:38.685Z",
"system": false,
"noteable_id": 98,
"noteable_type": "Issue",
"type": null,
"human_access": "Owner",
"note": "New note!",
"note_html": "\u003cp dir=\"auto\"\u003eNew note!\u003c/p\u003e",
"current_user": {
"can_edit": true
},
"discussion_id": "70d5c92a4039a36c70100c6691c18c27e4b0a790",
"emoji_awardable": true,
"award_emoji": [],
"toggle_award_path": "/gitlab-org/gitlab-ce/notes/1391/toggle_award_emoji",
"report_abuse_path": "/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F26%23note_1391\u0026user_id=1",
"path": "/gitlab-org/gitlab-ce/notes/1391"
}],
"individual_note": true
}];
export const discussionNoteServerResponse = [{
"id": "a3ed36e29b1957efb3b68c53e2d7a2b24b1df052",
"reply_id": "a3ed36e29b1957efb3b68c53e2d7a2b24b1df052",
"expanded": true,
"notes": [{
"id": 1471,
"attachment": {
"url": null,
"filename": null,
"image": false
},
"author": {
"id": 1,
"name": "Root",
"username": "root",
"state": "active",
"avatar_url": null,
"path": "/root"
},
"created_at": "2017-08-08T16:53:00.666Z",
"updated_at": "2017-08-08T16:53:00.666Z",
"system": false,
"noteable_id": 124,
"noteable_type": "Issue",
"noteable_iid": 29,
"type": "DiscussionNote",
"human_access": "Owner",
"note": "Adding a comment",
"note_html": "\u003cp dir=\"auto\"\u003eAdding a comment\u003c/p\u003e",
"current_user": {
"can_edit": true
},
"discussion_id": "a3ed36e29b1957efb3b68c53e2d7a2b24b1df052",
"emoji_awardable": true,
"award_emoji": [],
"toggle_award_path": "/gitlab-org/gitlab-ce/notes/1471/toggle_award_emoji",
"report_abuse_path": "/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F29%23note_1471\u0026user_id=1",
"path": "/gitlab-org/gitlab-ce/notes/1471"
}],
"individual_note": false
}];
import * as actions from '~/notes/stores/actions';
import testAction from './helpers';
import { discussionMock, notesDataMock, userDataMock, issueDataMock, individualNote } from '../mock_data';
describe('Actions Notes Store', () => {
describe('setNotesData', () => {
it('should set received notes data', (done) => {
testAction(actions.setNotesData, null, { notesData: {} }, [
{ type: 'SET_NOTES_DATA', payload: notesDataMock },
], done);
});
});
describe('setIssueData', () => {
it('should set received issue data', (done) => {
testAction(actions.setIssueData, null, { issueData: {} }, [
{ type: 'SET_ISSUE_DATA', payload: issueDataMock },
], done);
});
});
describe('setUserData', () => {
it('should set received user data', (done) => {
testAction(actions.setUserData, null, { userData: {} }, [
{ type: 'SET_USER_DATA', payload: userDataMock },
], done);
});
});
describe('setLastFetchedAt', () => {
it('should set received timestamp', (done) => {
testAction(actions.setLastFetchedAt, null, { lastFetchedAt: {} }, [
{ type: 'SET_LAST_FETCHED_AT', payload: 'timestamp' },
], done);
});
});
describe('setInitialNotes', () => {
it('should set initial notes', (done) => {
testAction(actions.setInitialNotes, null, { notes: [] }, [
{ type: 'SET_INITIAL_NOTES', payload: [individualNote] },
], done);
});
});
describe('setTargetNoteHash', () => {
it('should set target note hash', (done) => {
testAction(actions.setTargetNoteHash, null, { notes: [] }, [
{ type: 'SET_TARGET_NOTE_HASH', payload: 'hash' },
], done);
});
});
describe('toggleDiscussion', () => {
it('should toggle discussion', (done) => {
testAction(actions.toggleDiscussion, null, { notes: [discussionMock] }, [
{ type: 'TOGGLE_DISCUSSION', payload: { discussionId: discussionMock.id } },
], done);
});
});
});
import * as getters from '~/notes/stores/getters';
import { notesDataMock, userDataMock, issueDataMock, individualNote } from '../mock_data';
describe('Getters Notes Store', () => {
let state;
beforeEach(() => {
state = {
notes: [individualNote],
targetNoteHash: 'hash',
lastFetchedAt: 'timestamp',
notesData: notesDataMock,
userData: userDataMock,
issueData: issueDataMock,
};
});
describe('notes', () => {
it('should return all notes in the store', () => {
expect(getters.notes(state)).toEqual([individualNote]);
});
});
describe('targetNoteHash', () => {
it('should return `targetNoteHash`', () => {
expect(getters.targetNoteHash(state)).toEqual('hash');
});
});
describe('getNotesData', () => {
it('should return all data in `notesData`', () => {
expect(getters.getNotesData(state)).toEqual(notesDataMock);
});
});
describe('getIssueData', () => {
it('should return all data in `issueData`', () => {
expect(getters.getIssueData(state)).toEqual(issueDataMock);
});
});
describe('getUserData', () => {
it('should return all data in `userData`', () => {
expect(getters.getUserData(state)).toEqual(userDataMock);
});
});
describe('notesById', () => {
it('should return the note for the given id', () => {
expect(getters.notesById(state)).toEqual({ 1390: individualNote.notes[0] });
});
});
describe('getCurrentUserLastNote', () => {
it('should return the last note of the current user', () => {
expect(getters.getCurrentUserLastNote(state)).toEqual(individualNote.notes[0]);
});
});
});
/* eslint-disable */
/**
* helper for testing action with expected mutations
* https://vuex.vuejs.org/en/testing.html
*/
export default (action, payload, state, expectedMutations, done) => {
let count = 0;
// mock commit
const commit = (type, payload) => {
const mutation = expectedMutations[count];
try {
expect(mutation.type).to.equal(type);
if (payload) {
expect(mutation.payload).to.deep.equal(payload);
}
} catch (error) {
done(error);
}
count++;
if (count >= expectedMutations.length) {
done();
}
};
// call the action with mocked store and arguments
action({ commit, state }, payload);
// check if no mutations should have been dispatched
if (expectedMutations.length === 0) {
expect(count).to.equal(0);
done();
}
};
import mutations from '~/notes/stores/mutations';
import { note, discussionMock, notesDataMock, userDataMock, issueDataMock, individualNote } from '../mock_data';
describe('Mutation Notes Store', () => {
describe('ADD_NEW_NOTE', () => {
it('should add a new note to an array of notes', () => {
const state = { notes: [] };
mutations.ADD_NEW_NOTE(state, note);
expect(state).toEqual({
notes: [{
expanded: true,
id: note.discussion_id,
individual_note: true,
notes: [note],
reply_id: note.discussion_id,
}],
});
});
});
describe('ADD_NEW_REPLY_TO_DISCUSSION', () => {
it('should add a reply to a specific discussion', () => {
const state = { notes: [discussionMock] };
const newReply = Object.assign({}, note, { discussion_id: discussionMock.id });
mutations.ADD_NEW_REPLY_TO_DISCUSSION(state, newReply);
expect(state.notes[0].notes.length).toEqual(4);
});
});
describe('DELETE_NOTE', () => {
it('should delete a note ', () => {
const state = { notes: [discussionMock] };
const toDelete = discussionMock.notes[0];
const lengthBefore = discussionMock.notes.length;
mutations.DELETE_NOTE(state, toDelete);
expect(state.notes[0].notes.length).toEqual(lengthBefore - 1);
});
});
describe('REMOVE_PLACEHOLDER_NOTES', () => {
it('should remove all placeholder notes in indivudal notes and discussion', () => {
const placeholderNote = Object.assign({}, individualNote, { isPlaceholderNote: true });
const state = { notes: [placeholderNote] };
mutations.REMOVE_PLACEHOLDER_NOTES(state);
expect(state.notes).toEqual([]);
});
});
describe('SET_NOTES_DATA', () => {
it('should set an object with notesData', () => {
const state = {
notesData: {},
};
mutations.SET_NOTES_DATA(state, notesDataMock);
expect(state.notesData).toEqual(notesDataMock);
});
});
describe('SET_ISSUE_DATA', () => {
it('should set the issue data', () => {
const state = {
issueData: {},
};
mutations.SET_ISSUE_DATA(state, issueDataMock);
expect(state.issueData).toEqual(issueDataMock);
});
});
describe('SET_USER_DATA', () => {
it('should set the user data', () => {
const state = {
userData: {},
};
mutations.SET_USER_DATA(state, userDataMock);
expect(state.userData).toEqual(userDataMock);
});
});
describe('SET_INITIAL_NOTES', () => {
it('should set the initial notes received', () => {
const state = {
notes: [],
};
mutations.SET_INITIAL_NOTES(state, [note]);
expect(state.notes).toEqual([note]);
});
});
describe('SET_LAST_FETCHED_AT', () => {
it('should set timestamp', () => {
const state = {
lastFetchedAt: [],
};
mutations.SET_LAST_FETCHED_AT(state, 'timestamp');
expect(state.lastFetchedAt).toEqual('timestamp');
});
});
describe('SET_TARGET_NOTE_HASH', () => {
it('should set the note hash', () => {
const state = {
targetNoteHash: [],
};
mutations.SET_TARGET_NOTE_HASH(state, 'hash');
expect(state.targetNoteHash).toEqual('hash');
});
});
describe('SHOW_PLACEHOLDER_NOTE', () => {
it('should set a placeholder note', () => {
const state = {
notes: [],
};
mutations.SHOW_PLACEHOLDER_NOTE(state, note);
expect(state.notes[0].isPlaceholderNote).toEqual(true);
});
});
describe('TOGGLE_AWARD', () => {
it('should add award if user has not reacted yet', () => {
const state = {
notes: [note],
userData: userDataMock,
};
const data = {
note,
awardName: 'cartwheel',
};
mutations.TOGGLE_AWARD(state, data);
const lastIndex = state.notes[0].award_emoji.length - 1;
expect(state.notes[0].award_emoji[lastIndex]).toEqual({
name: 'cartwheel',
user: { id: userDataMock.id, name: userDataMock.name, username: userDataMock.username },
});
});
it('should remove award if user already reacted', () => {
const state = {
notes: [note],
userData: {
id: 1,
name: 'Administrator',
username: 'root',
},
};
const data = {
note,
awardName: 'bath_tone3',
};
mutations.TOGGLE_AWARD(state, data);
expect(state.notes[0].award_emoji.length).toEqual(2);
});
});
describe('TOGGLE_DISCUSSION', () => {
it('should open a closed discussion', () => {
const discussion = Object.assign({}, discussionMock, { expanded: false });
const state = {
notes: [discussion],
};
mutations.TOGGLE_DISCUSSION(state, { discussionId: discussion.id });
expect(state.notes[0].expanded).toEqual(true);
});
it('should close a opened discussion', () => {
const state = {
notes: [discussionMock],
};
mutations.TOGGLE_DISCUSSION(state, { discussionId: discussionMock.id });
expect(state.notes[0].expanded).toEqual(false);
});
});
describe('UPDATE_NOTE', () => {
it('should update a note', () => {
const state = {
notes: [individualNote],
};
const updated = Object.assign({}, individualNote.notes[0], { note: 'Foo' });
mutations.UPDATE_NOTE(state, updated);
expect(state.notes[0].notes[0].note).toEqual('Foo');
});
});
});
...@@ -32,14 +32,14 @@ import '~/notes'; ...@@ -32,14 +32,14 @@ import '~/notes';
describe('Notes', function() { describe('Notes', function() {
const FLASH_TYPE_ALERT = 'alert'; const FLASH_TYPE_ALERT = 'alert';
var commentsTemplate = 'issues/issue_with_comment.html.raw'; var commentsTemplate = 'merge_requests/merge_request_with_comment.html.raw';
preloadFixtures(commentsTemplate); preloadFixtures(commentsTemplate);
beforeEach(function () { beforeEach(function () {
loadFixtures(commentsTemplate); loadFixtures(commentsTemplate);
gl.utils.disableButtonIfEmptyField = _.noop; gl.utils.disableButtonIfEmptyField = _.noop;
window.project_uploads_path = 'http://test.host/uploads'; window.project_uploads_path = 'http://test.host/uploads';
$('body').data('page', 'projects:issues:show'); $('body').data('page', 'projects:merge_requets:show');
}); });
describe('task lists', function() { describe('task lists', function() {
...@@ -53,17 +53,19 @@ import '~/notes'; ...@@ -53,17 +53,19 @@ import '~/notes';
it('modifies the Markdown field', function() { it('modifies the Markdown field', function() {
const changeEvent = document.createEvent('HTMLEvents'); const changeEvent = document.createEvent('HTMLEvents');
changeEvent.initEvent('change', true, true); changeEvent.initEvent('change', true, true);
$('input[type=checkbox]').attr('checked', true)[0].dispatchEvent(changeEvent); $('input[type=checkbox]').attr('checked', true)[1].dispatchEvent(changeEvent);
expect($('.js-task-list-field').val()).toBe('- [x] Task List Item');
expect($('.js-task-list-field.original-task-list').val()).toBe('- [x] Task List Item');
}); });
it('submits an ajax request on tasklist:changed', function() { it('submits an ajax request on tasklist:changed', function() {
spyOn(jQuery, 'ajax').and.callFake(function(req) { spyOn(jQuery, 'ajax').and.callFake(function(req) {
expect(req.type).toBe('PATCH'); expect(req.type).toBe('PATCH');
expect(req.url).toBe('http://test.host/frontend-fixtures/issues-project/notes/1'); expect(req.url).toBe('http://test.host/frontend-fixtures/merge-requests-project/merge_requests/1.json');
return expect(req.data.note).not.toBe(null); return expect(req.data.note).not.toBe(null);
}); });
$('.js-task-list-field').trigger('tasklist:changed');
$('.js-task-list-field.js-note-text').trigger('tasklist:changed');
}); });
}); });
......
/* eslint-disable space-before-function-paren, no-return-assign, no-var, quotes */
/* global ShortcutsIssuable */ /* global ShortcutsIssuable */
import '~/copy_as_gfm'; import '~/copy_as_gfm';
import '~/shortcuts_issuable'; import '~/shortcuts_issuable';
(function() { describe('ShortcutsIssuable', () => {
describe('ShortcutsIssuable', function() { const fixtureName = 'merge_requests/diff_comment.html.raw';
var fixtureName = 'issues/open-issue.html.raw'; preloadFixtures(fixtureName);
preloadFixtures(fixtureName); beforeEach(() => {
beforeEach(function() { loadFixtures(fixtureName);
loadFixtures(fixtureName); document.querySelector('.js-new-note-form').classList.add('js-main-target-form');
document.querySelector('.js-new-note-form').classList.add('js-main-target-form'); this.shortcut = new ShortcutsIssuable(true);
this.shortcut = new ShortcutsIssuable(); });
}); describe('replyWithSelectedText', () => {
describe('replyWithSelectedText', function() { // Stub window.gl.utils.getSelectedFragment to return a node with the provided HTML.
var stubSelection; const stubSelection = (html) => {
// Stub window.gl.utils.getSelectedFragment to return a node with the provided HTML. window.gl.utils.getSelectedFragment = () => {
stubSelection = function(html) { const node = document.createElement('div');
window.gl.utils.getSelectedFragment = function() { node.innerHTML = html;
var node = document.createElement('div'); return node;
node.innerHTML = html;
return node;
};
}; };
beforeEach(function() { };
this.selector = 'form.js-main-target-form textarea#note_note'; beforeEach(() => {
this.selector = '.js-main-target-form #note_note';
});
describe('with empty selection', () => {
it('does not return an error', () => {
this.shortcut.replyWithSelectedText(true);
expect($(this.selector).val()).toBe('');
}); });
describe('with empty selection', function() { it('triggers `focus`', () => {
it('does not return an error', function() { this.shortcut.replyWithSelectedText(true);
this.shortcut.replyWithSelectedText(); expect(document.activeElement).toBe(document.querySelector(this.selector));
expect($(this.selector).val()).toBe('');
});
it('triggers `focus`', function() {
this.shortcut.replyWithSelectedText();
expect(document.activeElement).toBe(document.querySelector(this.selector));
});
}); });
describe('with any selection', function() { });
beforeEach(function() { describe('with any selection', () => {
stubSelection('<p>Selected text.</p>'); beforeEach(() => {
}); stubSelection('<p>Selected text.</p>');
it('leaves existing input intact', function() {
$(this.selector).val('This text was already here.');
expect($(this.selector).val()).toBe('This text was already here.');
this.shortcut.replyWithSelectedText();
expect($(this.selector).val()).toBe("This text was already here.\n\n> Selected text.\n\n");
});
it('triggers `input`', function() {
var triggered = false;
$(this.selector).on('input', function() {
triggered = true;
});
this.shortcut.replyWithSelectedText();
expect(triggered).toBe(true);
});
it('triggers `focus`', function() {
this.shortcut.replyWithSelectedText();
expect(document.activeElement).toBe(document.querySelector(this.selector));
});
}); });
describe('with a one-line selection', function() { it('leaves existing input intact', () => {
it('quotes the selection', function() { $(this.selector).val('This text was already here.');
stubSelection('<p>This text has been selected.</p>'); expect($(this.selector).val()).toBe('This text was already here.');
this.shortcut.replyWithSelectedText(); this.shortcut.replyWithSelectedText(true);
expect($(this.selector).val()).toBe("> This text has been selected.\n\n"); expect($(this.selector).val()).toBe('This text was already here.\n\n> Selected text.\n\n');
});
}); });
describe('with a multi-line selection', function() { it('triggers `input`', () => {
it('quotes the selected lines as a group', function() { let triggered = false;
stubSelection("<p>Selected line one.</p>\n\n<p>Selected line two.</p>\n\n<p>Selected line three.</p>"); $(this.selector).on('input', () => {
this.shortcut.replyWithSelectedText(); triggered = true;
expect($(this.selector).val()).toBe("> Selected line one.\n>\n> Selected line two.\n>\n> Selected line three.\n\n");
}); });
this.shortcut.replyWithSelectedText(true);
expect(triggered).toBe(true);
});
it('triggers `focus`', () => {
this.shortcut.replyWithSelectedText(true);
expect(document.activeElement).toBe(document.querySelector(this.selector));
});
});
describe('with a one-line selection', () => {
it('quotes the selection', () => {
stubSelection('<p>This text has been selected.</p>');
this.shortcut.replyWithSelectedText(true);
expect($(this.selector).val()).toBe('> This text has been selected.\n\n');
});
});
describe('with a multi-line selection', () => {
it('quotes the selected lines as a group', () => {
stubSelection('<p>Selected line one.</p>\n\n<p>Selected line two.</p>\n\n<p>Selected line three.</p>');
this.shortcut.replyWithSelectedText(true);
expect($(this.selector).val()).toBe('> Selected line one.\n>\n> Selected line two.\n>\n> Selected line three.\n\n');
}); });
}); });
}); });
}).call(window); });
/* global Shortcuts */ /* global Shortcuts */
describe('Shortcuts', () => { describe('Shortcuts', () => {
const fixtureName = 'issues/issue_with_comment.html.raw'; const fixtureName = 'merge_requests/diff_comment.html.raw';
const createEvent = (type, target) => $.Event(type, { const createEvent = (type, target) => $.Event(type, {
target, target,
}); });
......
import Vue from 'vue';
import confidentialIssue from '~/vue_shared/components/issue/confidential_issue_warning.vue';
describe('Confidential Issue Warning Component', () => {
let vm;
beforeEach(() => {
const Component = Vue.extend(confidentialIssue);
vm = new Component().$mount();
});
afterEach(() => {
vm.$destroy();
});
it('should render confidential issue warning information', () => {
expect(vm.$el.querySelector('i').className).toEqual('fa fa-eye-slash');
expect(vm.$el.querySelector('span').textContent.trim()).toEqual('This is a confidential issue. Your comment will not be visible to the public.');
});
});
...@@ -16,8 +16,8 @@ describe('Markdown field component', () => { ...@@ -16,8 +16,8 @@ describe('Markdown field component', () => {
}, },
template: ` template: `
<field-component <field-component
marodown-preview-url="/preview" markdown-preview-path="/preview"
markdown-docs="/docs" markdown-docs-path="/docs"
> >
<textarea <textarea
slot="textarea" slot="textarea"
...@@ -92,6 +92,7 @@ describe('Markdown field component', () => { ...@@ -92,6 +92,7 @@ describe('Markdown field component', () => {
it('renders GFM with jQuery', (done) => { it('renders GFM with jQuery', (done) => {
spyOn($.fn, 'renderGFM'); spyOn($.fn, 'renderGFM');
previewLink.click(); previewLink.click();
setTimeout(() => { setTimeout(() => {
...@@ -100,7 +101,7 @@ describe('Markdown field component', () => { ...@@ -100,7 +101,7 @@ describe('Markdown field component', () => {
).toHaveBeenCalled(); ).toHaveBeenCalled();
done(); done();
}); }, 0);
}); });
}); });
......
...@@ -8,7 +8,7 @@ import ZenMode from '~/zen_mode'; ...@@ -8,7 +8,7 @@ import ZenMode from '~/zen_mode';
var enterZen, escapeKeydown, exitZen; var enterZen, escapeKeydown, exitZen;
describe('ZenMode', function() { describe('ZenMode', function() {
var fixtureName = 'issues/open-issue.html.raw'; var fixtureName = 'merge_requests/merge_request_with_comment.html.raw';
preloadFixtures(fixtureName); preloadFixtures(fixtureName);
beforeEach(function() { beforeEach(function() {
loadFixtures(fixtureName); loadFixtures(fixtureName);
......
...@@ -41,4 +41,40 @@ describe AwardEmoji do ...@@ -41,4 +41,40 @@ describe AwardEmoji do
end end
end end
end end
describe 'expiring ETag cache' do
context 'on a note' do
let(:note) { create(:note_on_issue) }
let(:award_emoji) { build(:award_emoji, user: build(:user), awardable: note) }
it 'calls expire_etag_cache on the note when saved' do
expect(note).to receive(:expire_etag_cache)
award_emoji.save!
end
it 'calls expire_etag_cache on the note when destroyed' do
expect(note).to receive(:expire_etag_cache)
award_emoji.destroy!
end
end
context 'on another awardable' do
let(:issue) { create(:issue) }
let(:award_emoji) { build(:award_emoji, user: build(:user), awardable: issue) }
it 'does not call expire_etag_cache on the issue when saved' do
expect(issue).not_to receive(:expire_etag_cache)
award_emoji.save!
end
it 'does not call expire_etag_cache on the issue when destroyed' do
expect(issue).not_to receive(:expire_etag_cache)
award_emoji.destroy!
end
end
end
end end
require 'spec_helper'
describe NoteEntity do
include Gitlab::Routing
let(:request) { double('request', current_user: user, noteable: note.noteable) }
let(:entity) { described_class.new(note, request: request) }
let(:note) { create(:note) }
let(:user) { create(:user) }
subject { entity.as_json }
context 'basic note' do
it 'exposes correct elements' do
expect(subject).to include(:type, :author, :human_access, :note, :note_html, :current_user,
:discussion_id, :emoji_awardable, :award_emoji, :toggle_award_path, :report_abuse_path, :path, :attachment)
end
it 'does not expose elements for specific notes cases' do
expect(subject).not_to include(:last_edited_by, :last_edited_at, :system_note_icon_name)
end
it 'exposes author correctly' do
expect(subject[:author]).to include(:id, :name, :username, :state, :avatar_url, :path)
end
it 'does not expose web_url for author' do
expect(subject[:author]).not_to include(:web_url)
end
end
context 'when note was edited' do
before do
note.update(updated_at: 1.minute.from_now, updated_by: user)
end
it 'exposes last_edited_at and last_edited_by elements' do
expect(subject).to include(:last_edited_at, :last_edited_by)
end
end
context 'when note is a system note' do
before do
note.update(system: true)
end
it 'exposes system_note_icon_name element' do
expect(subject).to include(:system_note_icon_name)
end
end
end
...@@ -14,6 +14,8 @@ shared_examples 'discussion comments' do |resource_name| ...@@ -14,6 +14,8 @@ shared_examples 'discussion comments' do |resource_name|
find(submit_selector).click find(submit_selector).click
wait_for_requests
find(comments_selector, match: :first) find(comments_selector, match: :first)
new_comment = all(comments_selector).last new_comment = all(comments_selector).last
...@@ -26,6 +28,7 @@ shared_examples 'discussion comments' do |resource_name| ...@@ -26,6 +28,7 @@ shared_examples 'discussion comments' do |resource_name|
find("#{form_selector} .note-textarea").send_keys('a') find("#{form_selector} .note-textarea").send_keys('a')
find(close_selector).click find(close_selector).click
wait_for_requests
find(comments_selector, match: :first) find(comments_selector, match: :first)
find("#{comments_selector}.system-note") find("#{comments_selector}.system-note")
...@@ -76,12 +79,22 @@ shared_examples 'discussion comments' do |resource_name| ...@@ -76,12 +79,22 @@ shared_examples 'discussion comments' do |resource_name|
it 'clicking the ul padding or divider should not change the text' do it 'clicking the ul padding or divider should not change the text' do
find(menu_selector).trigger 'click' find(menu_selector).trigger 'click'
expect(page).to have_selector menu_selector if resource_name == 'issue'
expect(find(dropdown_selector)).to have_content 'Comment' expect(find(dropdown_selector)).to have_content 'Comment'
find(toggle_selector).click
find("#{menu_selector} .divider").trigger 'click'
else
find(menu_selector).trigger 'click'
find("#{menu_selector} .divider").trigger 'click' expect(page).to have_selector menu_selector
expect(find(dropdown_selector)).to have_content 'Comment'
find("#{menu_selector} .divider").trigger 'click'
expect(page).to have_selector menu_selector
end
expect(page).to have_selector menu_selector
expect(find(dropdown_selector)).to have_content 'Comment' expect(find(dropdown_selector)).to have_content 'Comment'
end end
...@@ -91,9 +104,8 @@ shared_examples 'discussion comments' do |resource_name| ...@@ -91,9 +104,8 @@ shared_examples 'discussion comments' do |resource_name|
all("#{menu_selector} li").last.click all("#{menu_selector} li").last.click
end end
it 'updates the submit button text, note_type input and closes the dropdown' do it 'updates the submit button text and closes the dropdown' do
expect(find(dropdown_selector)).to have_content 'Start discussion' expect(find(dropdown_selector)).to have_content 'Start discussion'
expect(find("#{form_selector} #note_type", visible: false).value).to eq('DiscussionNote')
expect(page).not_to have_selector menu_selector expect(page).not_to have_selector menu_selector
end end
...@@ -157,9 +169,8 @@ shared_examples 'discussion comments' do |resource_name| ...@@ -157,9 +169,8 @@ shared_examples 'discussion comments' do |resource_name|
find("#{menu_selector} li", match: :first).click find("#{menu_selector} li", match: :first).click
end end
it 'updates the submit button text, clears the note_type input and closes the dropdown' do it 'updates the submit button text and closes the dropdown' do
expect(find(dropdown_selector)).to have_content 'Comment' expect(find(dropdown_selector)).to have_content 'Comment'
expect(find("#{form_selector} #note_type", visible: false).value).to eq('')
expect(page).not_to have_selector menu_selector expect(page).not_to have_selector menu_selector
end end
......
...@@ -21,7 +21,7 @@ shared_examples 'issuable record that supports quick actions in its description ...@@ -21,7 +21,7 @@ shared_examples 'issuable record that supports quick actions in its description
before do before do
project.team << [master, :master] project.team << [master, :master]
sign_in(master) gitlab_sign_in(master)
end end
after do after do
...@@ -119,16 +119,15 @@ shared_examples 'issuable record that supports quick actions in its description ...@@ -119,16 +119,15 @@ shared_examples 'issuable record that supports quick actions in its description
guest = create(:user) guest = create(:user)
project.add_guest(guest) project.add_guest(guest)
sign_out(:user) gitlab_sign_out
sign_in(guest) gitlab_sign_in(guest)
visit public_send("namespace_project_#{issuable_type}_path", project.namespace, project, issuable) visit public_send("namespace_project_#{issuable_type}_path", project.namespace, project, issuable)
end end
it "does not close the #{issuable_type}" do it "does not close the #{issuable_type}" do
write_note("/close") write_note("/close")
expect(page).not_to have_content '/close' expect(page).to have_content '/close'
expect(page).not_to have_content 'Commands applied' expect(page).not_to have_content 'Commands applied'
expect(issuable).to be_open expect(issuable).to be_open
...@@ -158,16 +157,15 @@ shared_examples 'issuable record that supports quick actions in its description ...@@ -158,16 +157,15 @@ shared_examples 'issuable record that supports quick actions in its description
guest = create(:user) guest = create(:user)
project.add_guest(guest) project.add_guest(guest)
sign_out(:user) gitlab_sign_out
sign_in(guest) gitlab_sign_in(guest)
visit public_send("namespace_project_#{issuable_type}_path", project.namespace, project, issuable) visit public_send("namespace_project_#{issuable_type}_path", project.namespace, project, issuable)
end end
it "does not reopen the #{issuable_type}" do it "does not reopen the #{issuable_type}" do
write_note("/reopen") write_note("/reopen")
expect(page).not_to have_content '/reopen' expect(page).to have_content '/reopen'
expect(page).not_to have_content 'Commands applied' expect(page).not_to have_content 'Commands applied'
expect(issuable).to be_closed expect(issuable).to be_closed
...@@ -192,15 +190,15 @@ shared_examples 'issuable record that supports quick actions in its description ...@@ -192,15 +190,15 @@ shared_examples 'issuable record that supports quick actions in its description
guest = create(:user) guest = create(:user)
project.add_guest(guest) project.add_guest(guest)
sign_out(:user) gitlab_sign_out
sign_in(guest) gitlab_sign_in(guest)
visit public_send("namespace_project_#{issuable_type}_path", project.namespace, project, issuable) visit public_send("namespace_project_#{issuable_type}_path", project.namespace, project, issuable)
end end
it "does not reopen the #{issuable_type}" do it "does not reopen the #{issuable_type}" do
write_note("/title Awesome new title") write_note("/title Awesome new title")
expect(page).not_to have_content '/title' expect(page).to have_content '/title'
expect(page).not_to have_content 'Commands applied' expect(page).not_to have_content 'Commands applied'
expect(issuable.reload.title).not_to eq 'Awesome new title' expect(issuable.reload.title).not_to eq 'Awesome new title'
...@@ -292,7 +290,7 @@ shared_examples 'issuable record that supports quick actions in its description ...@@ -292,7 +290,7 @@ shared_examples 'issuable record that supports quick actions in its description
end end
end end
describe "preview of note on #{issuable_type}" do describe "preview of note on #{issuable_type}", js: true do
it 'removes quick actions from note and explains them' do it 'removes quick actions from note and explains them' do
create(:user, username: 'bob') create(:user, username: 'bob')
......
require 'spec_helper' require 'spec_helper'
shared_examples 'reportable note' do shared_examples 'reportable note' do |type|
include NotesHelper include NotesHelper
let(:comment) { find("##{ActionView::RecordIdentifier.dom_id(note)}") } let(:comment) { find("##{ActionView::RecordIdentifier.dom_id(note)}") }
...@@ -20,7 +20,12 @@ shared_examples 'reportable note' do ...@@ -20,7 +20,12 @@ shared_examples 'reportable note' do
open_dropdown(dropdown) open_dropdown(dropdown)
expect(dropdown).to have_link('Report as abuse', href: abuse_report_path) expect(dropdown).to have_link('Report as abuse', href: abuse_report_path)
expect(dropdown).to have_link('Delete comment', href: note_url(note, project))
if type == 'issue'
expect(dropdown).to have_button('Delete comment')
else
expect(dropdown).to have_link('Delete comment', href: note_url(note, project))
end
end end
it 'Report button links to a report page' do it 'Report button links to a report page' do
......
...@@ -6307,6 +6307,10 @@ vue@^2.2.6: ...@@ -6307,6 +6307,10 @@ vue@^2.2.6:
version "2.2.6" version "2.2.6"
resolved "https://registry.yarnpkg.com/vue/-/vue-2.2.6.tgz#451714b394dd6d4eae7b773c40c2034a59621aed" resolved "https://registry.yarnpkg.com/vue/-/vue-2.2.6.tgz#451714b394dd6d4eae7b773c40c2034a59621aed"
vuex@^2.3.1:
version "2.3.1"
resolved "https://registry.yarnpkg.com/vuex/-/vuex-2.3.1.tgz#cde8e997c1f9957719bc7dea154f9aa691d981a6"
watchpack@^1.4.0: watchpack@^1.4.0:
version "1.4.0" version "1.4.0"
resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-1.4.0.tgz#4a1472bcbb952bd0a9bb4036801f954dfb39faac" resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-1.4.0.tgz#4a1472bcbb952bd0a9bb4036801f954dfb39faac"
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment