summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJacob Schatz <jschatz@gitlab.com>2017-09-02 11:55:18 +0000
committerJacob Schatz <jschatz@gitlab.com>2017-09-02 11:55:18 +0000
commit81002745184df28fc9d969afc524986279c653bb (patch)
tree386724b936531148dfe98110aa214b6deb0aa84a
parent1e60725174cf8cfac1b54bbcdb1453d74bfdd37e (diff)
parent92edb3edab89dcb7b87bad3ac0fe7fed404fea85 (diff)
downloadgitlab-ce-81002745184df28fc9d969afc524986279c653bb.tar.gz
Merge branch 'issue-discussions-refactor' into 'master'
Issue discussions Vue refactor See merge request !12069
-rw-r--r--app/assets/javascripts/autosave.js24
-rw-r--r--app/assets/javascripts/awards_handler.js30
-rw-r--r--app/assets/javascripts/behaviors/quick_submit.js5
-rw-r--r--app/assets/javascripts/dispatcher.js3
-rw-r--r--app/assets/javascripts/dropzone_input.js4
-rw-r--r--app/assets/javascripts/issue.js7
-rw-r--r--app/assets/javascripts/issue_show/components/app.vue12
-rw-r--r--app/assets/javascripts/issue_show/components/fields/description.vue8
-rw-r--r--app/assets/javascripts/issue_show/components/fields/project_move.vue4
-rw-r--r--app/assets/javascripts/issue_show/components/form.vue12
-rw-r--r--app/assets/javascripts/issue_show/index.js6
-rw-r--r--app/assets/javascripts/lib/utils/common_utils.js14
-rw-r--r--app/assets/javascripts/notes/components/issue_comment_form.vue347
-rw-r--r--app/assets/javascripts/notes/components/issue_discussion.vue232
-rw-r--r--app/assets/javascripts/notes/components/issue_note.vue186
-rw-r--r--app/assets/javascripts/notes/components/issue_note_actions.vue167
-rw-r--r--app/assets/javascripts/notes/components/issue_note_attachment.vue37
-rw-r--r--app/assets/javascripts/notes/components/issue_note_awards_list.vue228
-rw-r--r--app/assets/javascripts/notes/components/issue_note_body.vue122
-rw-r--r--app/assets/javascripts/notes/components/issue_note_edited_text.vue47
-rw-r--r--app/assets/javascripts/notes/components/issue_note_form.vue166
-rw-r--r--app/assets/javascripts/notes/components/issue_note_header.vue118
-rw-r--r--app/assets/javascripts/notes/components/issue_note_icons.js37
-rw-r--r--app/assets/javascripts/notes/components/issue_note_signed_out_widget.vue28
-rw-r--r--app/assets/javascripts/notes/components/issue_notes_app.vue151
-rw-r--r--app/assets/javascripts/notes/components/issue_placeholder_note.vue53
-rw-r--r--app/assets/javascripts/notes/components/issue_placeholder_system_note.vue21
-rw-r--r--app/assets/javascripts/notes/components/issue_system_note.vue55
-rw-r--r--app/assets/javascripts/notes/constants.js11
-rw-r--r--app/assets/javascripts/notes/event_hub.js3
-rw-r--r--app/assets/javascripts/notes/index.js35
-rw-r--r--app/assets/javascripts/notes/mixins/autosave.js16
-rw-r--r--app/assets/javascripts/notes/services/issue_notes_service.js35
-rw-r--r--app/assets/javascripts/notes/stores/actions.js217
-rw-r--r--app/assets/javascripts/notes/stores/getters.js31
-rw-r--r--app/assets/javascripts/notes/stores/index.js23
-rw-r--r--app/assets/javascripts/notes/stores/mutation_types.js14
-rw-r--r--app/assets/javascripts/notes/stores/mutations.js151
-rw-r--r--app/assets/javascripts/notes/stores/utils.js31
-rw-r--r--app/assets/javascripts/shortcuts_issuable.js15
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js4
-rw-r--r--app/assets/javascripts/vue_shared/components/issue/confidential_issue_warning.vue16
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue97
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/toolbar.vue93
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss4
-rw-r--r--app/assets/stylesheets/pages/issuable.scss1
-rw-r--r--app/assets/stylesheets/pages/issues.scss4
-rw-r--r--app/assets/stylesheets/pages/note_form.scss8
-rw-r--r--app/assets/stylesheets/pages/notes.scss22
-rw-r--r--app/controllers/concerns/notes_actions.rb56
-rw-r--r--app/controllers/projects/issues_controller.rb22
-rw-r--r--app/helpers/issuables_helper.rb8
-rw-r--r--app/helpers/issues_helper.rb2
-rw-r--r--app/helpers/notes_helper.rb8
-rw-r--r--app/helpers/system_note_helper.rb8
-rw-r--r--app/models/award_emoji.rb7
-rw-r--r--app/models/concerns/noteable.rb6
-rw-r--r--app/models/discussion.rb4
-rw-r--r--app/models/issue.rb4
-rw-r--r--app/models/note.rb22
-rw-r--r--app/serializers/award_emoji_entity.rb4
-rw-r--r--app/serializers/discussion_entity.rb10
-rw-r--r--app/serializers/discussion_serializer.rb3
-rw-r--r--app/serializers/issuable_entity.rb2
-rw-r--r--app/serializers/issue_entity.rb20
-rw-r--r--app/serializers/note_attachment_entity.rb5
-rw-r--r--app/serializers/note_entity.rb60
-rw-r--r--app/serializers/note_serializer.rb3
-rw-r--r--app/serializers/note_user_entity.rb3
-rw-r--r--app/serializers/user_serializer.rb3
-rw-r--r--app/views/discussions/_headline.html.haml2
-rw-r--r--app/views/projects/_md_preview.html.haml5
-rw-r--r--app/views/projects/issues/_discussion.html.haml14
-rw-r--r--app/views/projects/issues/show.html.haml13
-rw-r--r--app/views/projects/merge_requests/_mr_title.html.haml2
-rw-r--r--app/views/shared/issuable/_close_reopen_button.html.haml4
-rw-r--r--app/views/shared/issuable/_close_reopen_report_toggle.html.haml2
-rw-r--r--app/views/shared/notes/_notes_with_form.html.haml4
-rw-r--r--config/routes/project.rb1
-rw-r--r--config/webpack.config.js2
-rw-r--r--features/steps/project/issues/issues.rb1
-rw-r--r--features/steps/shared/note.rb7
-rw-r--r--lib/api/entities.rb4
-rw-r--r--package.json1
-rw-r--r--spec/controllers/projects/issues_controller_spec.rb15
-rw-r--r--spec/controllers/projects/notes_controller_spec.rb17
-rw-r--r--spec/features/issues/award_emoji_spec.rb4
-rw-r--r--spec/features/issues/gfm_autocomplete_spec.rb44
-rw-r--r--spec/features/issues/markdown_toolbar_spec.rb20
-rw-r--r--spec/features/issues/note_polling_spec.rb37
-rw-r--r--spec/features/participants_autocomplete_spec.rb14
-rw-r--r--spec/features/reportable_note/commit_spec.rb4
-rw-r--r--spec/features/reportable_note/issue_spec.rb2
-rw-r--r--spec/features/reportable_note/merge_request_spec.rb4
-rw-r--r--spec/features/reportable_note/snippets_spec.rb2
-rw-r--r--spec/features/task_lists_spec.rb11
-rw-r--r--spec/features/uploads/user_uploads_file_to_note_spec.rb1
-rw-r--r--spec/fixtures/api/schemas/entities/merge_request.json2
-rw-r--r--spec/javascripts/awards_handler_spec.js7
-rw-r--r--spec/javascripts/behaviors/quick_submit_spec.js190
-rw-r--r--spec/javascripts/fixtures/merge_requests.rb5
-rw-r--r--spec/javascripts/issue_show/components/app_spec.js6
-rw-r--r--spec/javascripts/issue_show/components/fields/description_spec.js4
-rw-r--r--spec/javascripts/issue_show/components/fields/project_move_spec.js2
-rw-r--r--spec/javascripts/issue_show/components/form_spec.js6
-rw-r--r--spec/javascripts/notes/components/issue_comment_form_spec.js134
-rw-r--r--spec/javascripts/notes/components/issue_discussion_spec.js50
-rw-r--r--spec/javascripts/notes/components/issue_note_actions_spec.js91
-rw-r--r--spec/javascripts/notes/components/issue_note_app_spec.js255
-rw-r--r--spec/javascripts/notes/components/issue_note_attachment_spec.js23
-rw-r--r--spec/javascripts/notes/components/issue_note_awards_list_spec.js56
-rw-r--r--spec/javascripts/notes/components/issue_note_body_spec.js46
-rw-r--r--spec/javascripts/notes/components/issue_note_edited_text_spec.js47
-rw-r--r--spec/javascripts/notes/components/issue_note_form_spec.js112
-rw-r--r--spec/javascripts/notes/components/issue_note_header_spec.js94
-rw-r--r--spec/javascripts/notes/components/issue_note_signed_out_widget_spec.js37
-rw-r--r--spec/javascripts/notes/components/issue_note_spec.js44
-rw-r--r--spec/javascripts/notes/components/issue_placeholder_note_spec.js39
-rw-r--r--spec/javascripts/notes/components/issue_placeholder_system_note_spec.js24
-rw-r--r--spec/javascripts/notes/components/issue_system_note_spec.js53
-rw-r--r--spec/javascripts/notes/mock_data.js449
-rw-r--r--spec/javascripts/notes/stores/actions_spec.js62
-rw-r--r--spec/javascripts/notes/stores/getters_spec.js58
-rw-r--r--spec/javascripts/notes/stores/helpers.js37
-rw-r--r--spec/javascripts/notes/stores/mutation_spec.js207
-rw-r--r--spec/javascripts/notes_spec.js14
-rw-r--r--spec/javascripts/shortcuts_issuable_spec.js124
-rw-r--r--spec/javascripts/shortcuts_spec.js2
-rw-r--r--spec/javascripts/vue_shared/components/issue/confidential_issue_warning_spec.js20
-rw-r--r--spec/javascripts/vue_shared/components/markdown/field_spec.js7
-rw-r--r--spec/javascripts/zen_mode_spec.js2
-rw-r--r--spec/models/award_emoji_spec.rb36
-rw-r--r--spec/serializers/note_entity_spec.rb51
-rw-r--r--spec/support/features/discussion_comments_shared_example.rb27
-rw-r--r--spec/support/features/issuable_slash_commands_shared_examples.rb24
-rw-r--r--spec/support/features/reportable_note_shared_examples.rb9
-rw-r--r--yarn.lock4
137 files changed, 5457 insertions, 455 deletions
diff --git a/app/assets/javascripts/autosave.js b/app/assets/javascripts/autosave.js
index cfab6c40b34..4d2d4db7c0e 100644
--- a/app/assets/javascripts/autosave.js
+++ b/app/assets/javascripts/autosave.js
@@ -2,17 +2,17 @@
import AccessorUtilities from './lib/utils/accessor';
window.Autosave = (function() {
- function Autosave(field, key) {
+ function Autosave(field, key, resource) {
this.field = field;
this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe();
-
+ this.resource = resource;
if (key.join != null) {
- key = key.join("/");
+ key = key.join('/');
}
- this.key = "autosave/" + key;
- this.field.data("autosave", this);
+ this.key = 'autosave/' + key;
+ this.field.data('autosave', this);
this.restore();
- this.field.on("input", (function(_this) {
+ this.field.on('input', (function(_this) {
return function() {
return _this.save();
};
@@ -29,7 +29,17 @@ window.Autosave = (function() {
if ((text != null ? text.length : void 0) > 0) {
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() {
diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js
index 097f79a250a..22fa1f2a609 100644
--- a/app/assets/javascripts/awards_handler.js
+++ b/app/assets/javascripts/awards_handler.js
@@ -109,6 +109,7 @@ class AwardsHandler {
}
$thumbsBtn.toggleClass('disabled', $userAuthored);
+ $thumbsBtn.prop('disabled', $userAuthored);
}
// Create the emoji menu with the first category of emojis.
@@ -234,14 +235,33 @@ class AwardsHandler {
}
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 $emojiButton = this.findEmojiIcon(votesBlock, normalizedEmoji).parent();
+
this.postEmoji($emojiButton, awardUrl, normalizedEmoji, () => {
this.addAwardToEmojiBar(votesBlock, normalizedEmoji, checkMutuality);
return typeof callback === 'function' ? callback() : undefined;
});
+
$('.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) {
@@ -268,6 +288,14 @@ class AwardsHandler {
}
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');
let resultantVotesBlock = currentBlock;
if (currentBlock.length === 0) {
diff --git a/app/assets/javascripts/behaviors/quick_submit.js b/app/assets/javascripts/behaviors/quick_submit.js
index bc693616460..79702c54852 100644
--- a/app/assets/javascripts/behaviors/quick_submit.js
+++ b/app/assets/javascripts/behaviors/quick_submit.js
@@ -44,7 +44,10 @@ $(document).on('keydown.quick_submit', '.js-quick-submit', (e) => {
if (!$submitButton.attr('disabled')) {
$submitButton.trigger('click', [e]);
- $submitButton.disable();
+
+ if (!gl.utils.isInIssuePage()) {
+ $submitButton.disable();
+ }
}
});
diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js
index c70a17104fd..3dec4de06ec 100644
--- a/app/assets/javascripts/dispatcher.js
+++ b/app/assets/javascripts/dispatcher.js
@@ -99,7 +99,7 @@ import initChangesDropdown from './init_changes_dropdown';
path = page.split(':');
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 enableGFM = gl.utils.convertPermissionToBoolean(el.dataset.supportsAutocomplete);
gfm.setup($(el), {
@@ -172,7 +172,6 @@ import initChangesDropdown from './init_changes_dropdown';
shortcut_handler = new ShortcutsIssuable();
new ZenMode();
initIssuableSidebar();
- initNotes();
break;
case 'dashboard:milestones:index':
new ProjectSelect();
diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js
index 6d19a6d9b3a..975903159be 100644
--- a/app/assets/javascripts/dropzone_input.js
+++ b/app/assets/javascripts/dropzone_input.js
@@ -128,7 +128,7 @@ window.DropzoneInput = (function() {
// removeAllFiles(true) stops uploading files (if any)
// and remove them from dropzone files queue.
$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.stopPropagation();
@@ -140,7 +140,7 @@ window.DropzoneInput = (function() {
// and add that files to the dropzone files queue again.
// addFile() adds file to dropzone files queue and upload it.
$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;
e.preventDefault();
diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js
index 2bee4fb045a..7c4f4da6127 100644
--- a/app/assets/javascripts/issue.js
+++ b/app/assets/javascripts/issue.js
@@ -42,7 +42,7 @@ class Issue {
initIssueBtnEventListeners() {
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;
e.preventDefault();
e.stopImmediatePropagation();
@@ -66,12 +66,11 @@ class Issue {
const projectIssuesCounter = $('.issue_counter');
if ('id' in data) {
- $(document).trigger('issuable:change');
-
const isClosed = $button.hasClass('btn-close');
isClosedBadge.toggleClass('hidden', !isClosed);
isOpenBadge.toggleClass('hidden', isClosed);
+ $(document).trigger('issuable:change', isClosed);
this.toggleCloseReopenButton(isClosed);
let numProjectIssues = Number(projectIssuesCounter.text().replace(/[^\d]/, ''));
@@ -121,7 +120,7 @@ class Issue {
static submitNoteForm(form) {
var noteText;
noteText = form.find("textarea.js-note-text").val();
- if (noteText.trim().length > 0) {
+ if (noteText && noteText.trim().length > 0) {
return form.submit();
}
}
diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue
index efae112923d..eaaafd4c149 100644
--- a/app/assets/javascripts/issue_show/components/app.vue
+++ b/app/assets/javascripts/issue_show/components/app.vue
@@ -80,11 +80,11 @@ export default {
type: Boolean,
required: true,
},
- markdownPreviewUrl: {
+ markdownPreviewPath: {
type: String,
required: true,
},
- markdownDocs: {
+ markdownDocsPath: {
type: String,
required: true,
},
@@ -96,7 +96,7 @@ export default {
type: String,
required: true,
},
- projectsAutocompleteUrl: {
+ projectsAutocompletePath: {
type: String,
required: true,
},
@@ -242,11 +242,11 @@ export default {
:can-move="canMove"
:can-destroy="canDestroy"
:issuable-templates="issuableTemplates"
- :markdown-docs="markdownDocs"
- :markdown-preview-url="markdownPreviewUrl"
+ :markdown-docs-path="markdownDocsPath"
+ :markdown-preview-path="markdownPreviewPath"
:project-path="projectPath"
:project-namespace="projectNamespace"
- :projects-autocomplete-url="projectsAutocompleteUrl"
+ :projects-autocomplete-path="projectsAutocompletePath"
/>
<div v-else>
<title-component
diff --git a/app/assets/javascripts/issue_show/components/fields/description.vue b/app/assets/javascripts/issue_show/components/fields/description.vue
index 27b1b814f9a..dc902eefc5f 100644
--- a/app/assets/javascripts/issue_show/components/fields/description.vue
+++ b/app/assets/javascripts/issue_show/components/fields/description.vue
@@ -10,11 +10,11 @@
type: Object,
required: true,
},
- markdownPreviewUrl: {
+ markdownPreviewPath: {
type: String,
required: true,
},
- markdownDocs: {
+ markdownDocsPath: {
type: String,
required: true,
},
@@ -36,8 +36,8 @@
Description
</label>
<markdown-field
- :markdown-preview-url="markdownPreviewUrl"
- :markdown-docs="markdownDocs">
+ :markdown-preview-path="markdownPreviewPath"
+ :markdown-docs-path="markdownDocsPath">
<textarea
id="issue-description"
class="note-textarea js-gfm-input js-autosize markdown-area"
diff --git a/app/assets/javascripts/issue_show/components/fields/project_move.vue b/app/assets/javascripts/issue_show/components/fields/project_move.vue
index 7bf2be8b28a..e514bebc5f6 100644
--- a/app/assets/javascripts/issue_show/components/fields/project_move.vue
+++ b/app/assets/javascripts/issue_show/components/fields/project_move.vue
@@ -10,7 +10,7 @@
type: Object,
required: true,
},
- projectsAutocompleteUrl: {
+ projectsAutocompletePath: {
type: String,
required: true,
},
@@ -20,7 +20,7 @@
$moveDropdown.select2({
ajax: {
- url: this.projectsAutocompleteUrl,
+ url: this.projectsAutocompletePath,
quietMillis: 125,
data(term, page, context) {
return {
diff --git a/app/assets/javascripts/issue_show/components/form.vue b/app/assets/javascripts/issue_show/components/form.vue
index 76ec3dc9a5d..d9b53bc55cf 100644
--- a/app/assets/javascripts/issue_show/components/form.vue
+++ b/app/assets/javascripts/issue_show/components/form.vue
@@ -26,11 +26,11 @@
required: false,
default: () => [],
},
- markdownPreviewUrl: {
+ markdownPreviewPath: {
type: String,
required: true,
},
- markdownDocs: {
+ markdownDocsPath: {
type: String,
required: true,
},
@@ -42,7 +42,7 @@
type: String,
required: true,
},
- projectsAutocompleteUrl: {
+ projectsAutocompletePath: {
type: String,
required: true,
},
@@ -89,14 +89,14 @@
</div>
<description-field
:form-state="formState"
- :markdown-preview-url="markdownPreviewUrl"
- :markdown-docs="markdownDocs" />
+ :markdown-preview-path="markdownPreviewPath"
+ :markdown-docs-path="markdownDocsPath" />
<confidential-checkbox
:form-state="formState" />
<project-move
v-if="canMove"
:form-state="formState"
- :projects-autocomplete-url="projectsAutocompleteUrl" />
+ :projects-autocomplete-path="projectsAutocompletePath" />
<edit-actions
:form-state="formState"
:can-destroy="canDestroy" />
diff --git a/app/assets/javascripts/issue_show/index.js b/app/assets/javascripts/issue_show/index.js
index ad8cb6465e2..60b69b300fd 100644
--- a/app/assets/javascripts/issue_show/index.js
+++ b/app/assets/javascripts/issue_show/index.js
@@ -37,11 +37,11 @@ document.addEventListener('DOMContentLoaded', () => {
initialDescriptionText: this.initialDescriptionText,
issuableTemplates: this.issuableTemplates,
isConfidential: this.isConfidential,
- markdownPreviewUrl: this.markdownPreviewUrl,
- markdownDocs: this.markdownDocs,
+ markdownPreviewPath: this.markdownPreviewPath,
+ markdownDocsPath: this.markdownDocsPath,
projectPath: this.projectPath,
projectNamespace: this.projectNamespace,
- projectsAutocompleteUrl: this.projectsAutocompleteUrl,
+ projectsAutocompletePath: this.projectsAutocompletePath,
updatedAt: this.updatedAt,
updatedByName: this.updatedByName,
updatedByPath: this.updatedByPath,
diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js
index b8f4f4eaba3..b8bebe1894f 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -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) {
return $.ajax({
type: "GET",
@@ -167,11 +174,12 @@
};
gl.utils.scrollToElement = function($el) {
- var top = $el.offset().top;
- gl.mrTabsHeight = gl.mrTabsHeight || $('.merge-request-tabs').height();
+ const top = $el.offset().top;
+ const mrTabsHeight = $('.merge-request-tabs').height() || 0;
+ const headerHeight = $('.navbar-gitlab').height() || 0;
return $('body, html').animate({
- scrollTop: top - (gl.mrTabsHeight)
+ scrollTop: top - mrTabsHeight - headerHeight,
}, 200);
};
diff --git a/app/assets/javascripts/notes/components/issue_comment_form.vue b/app/assets/javascripts/notes/components/issue_comment_form.vue
new file mode 100644
index 00000000000..16f4e22aa9b
--- /dev/null
+++ b/app/assets/javascripts/notes/components/issue_comment_form.vue
@@ -0,0 +1,347 @@
+<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>
diff --git a/app/assets/javascripts/notes/components/issue_discussion.vue b/app/assets/javascripts/notes/components/issue_discussion.vue
new file mode 100644
index 00000000000..b131ef4b182
--- /dev/null
+++ b/app/assets/javascripts/notes/components/issue_discussion.vue
@@ -0,0 +1,232 @@
+<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>
diff --git a/app/assets/javascripts/notes/components/issue_note.vue b/app/assets/javascripts/notes/components/issue_note.vue
new file mode 100644
index 00000000000..3483f6c7538
--- /dev/null
+++ b/app/assets/javascripts/notes/components/issue_note.vue
@@ -0,0 +1,186 @@
+<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>
diff --git a/app/assets/javascripts/notes/components/issue_note_actions.vue b/app/assets/javascripts/notes/components/issue_note_actions.vue
new file mode 100644
index 00000000000..60c172321d1
--- /dev/null
+++ b/app/assets/javascripts/notes/components/issue_note_actions.vue
@@ -0,0 +1,167 @@
+<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>
diff --git a/app/assets/javascripts/notes/components/issue_note_attachment.vue b/app/assets/javascripts/notes/components/issue_note_attachment.vue
new file mode 100644
index 00000000000..7134a3eb47e
--- /dev/null
+++ b/app/assets/javascripts/notes/components/issue_note_attachment.vue
@@ -0,0 +1,37 @@
+<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>
diff --git a/app/assets/javascripts/notes/components/issue_note_awards_list.vue b/app/assets/javascripts/notes/components/issue_note_awards_list.vue
new file mode 100644
index 00000000000..d42e61e3899
--- /dev/null
+++ b/app/assets/javascripts/notes/components/issue_note_awards_list.vue
@@ -0,0 +1,228 @@
+<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>
diff --git a/app/assets/javascripts/notes/components/issue_note_body.vue b/app/assets/javascripts/notes/components/issue_note_body.vue
new file mode 100644
index 00000000000..5f9003bfd87
--- /dev/null
+++ b/app/assets/javascripts/notes/components/issue_note_body.vue
@@ -0,0 +1,122 @@
+<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>
diff --git a/app/assets/javascripts/notes/components/issue_note_edited_text.vue b/app/assets/javascripts/notes/components/issue_note_edited_text.vue
new file mode 100644
index 00000000000..49e09f0ecc5
--- /dev/null
+++ b/app/assets/javascripts/notes/components/issue_note_edited_text.vue
@@ -0,0 +1,47 @@
+<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>
diff --git a/app/assets/javascripts/notes/components/issue_note_form.vue b/app/assets/javascripts/notes/components/issue_note_form.vue
new file mode 100644
index 00000000000..626c0f2ce18
--- /dev/null
+++ b/app/assets/javascripts/notes/components/issue_note_form.vue
@@ -0,0 +1,166 @@
+<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>
diff --git a/app/assets/javascripts/notes/components/issue_note_header.vue b/app/assets/javascripts/notes/components/issue_note_header.vue
new file mode 100644
index 00000000000..63aa3d777d0
--- /dev/null
+++ b/app/assets/javascripts/notes/components/issue_note_header.vue
@@ -0,0 +1,118 @@
+<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>
diff --git a/app/assets/javascripts/notes/components/issue_note_icons.js b/app/assets/javascripts/notes/components/issue_note_icons.js
new file mode 100644
index 00000000000..d8e3cb4bc01
--- /dev/null
+++ b/app/assets/javascripts/notes/components/issue_note_icons.js
@@ -0,0 +1,37 @@
+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,
+};
diff --git a/app/assets/javascripts/notes/components/issue_note_signed_out_widget.vue b/app/assets/javascripts/notes/components/issue_note_signed_out_widget.vue
new file mode 100644
index 00000000000..77af3594c1c
--- /dev/null
+++ b/app/assets/javascripts/notes/components/issue_note_signed_out_widget.vue
@@ -0,0 +1,28 @@
+<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>
diff --git a/app/assets/javascripts/notes/components/issue_notes_app.vue b/app/assets/javascripts/notes/components/issue_notes_app.vue
new file mode 100644
index 00000000000..b6fc5e5036f
--- /dev/null
+++ b/app/assets/javascripts/notes/components/issue_notes_app.vue
@@ -0,0 +1,151 @@
+<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>
diff --git a/app/assets/javascripts/notes/components/issue_placeholder_note.vue b/app/assets/javascripts/notes/components/issue_placeholder_note.vue
new file mode 100644
index 00000000000..6921d91372f
--- /dev/null
+++ b/app/assets/javascripts/notes/components/issue_placeholder_note.vue
@@ -0,0 +1,53 @@
+<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>
diff --git a/app/assets/javascripts/notes/components/issue_placeholder_system_note.vue b/app/assets/javascripts/notes/components/issue_placeholder_system_note.vue
new file mode 100644
index 00000000000..80a8ef56a83
--- /dev/null
+++ b/app/assets/javascripts/notes/components/issue_placeholder_system_note.vue
@@ -0,0 +1,21 @@
+<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>
diff --git a/app/assets/javascripts/notes/components/issue_system_note.vue b/app/assets/javascripts/notes/components/issue_system_note.vue
new file mode 100644
index 00000000000..5bb8f871b9d
--- /dev/null
+++ b/app/assets/javascripts/notes/components/issue_system_note.vue
@@ -0,0 +1,55 @@
+<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>
diff --git a/app/assets/javascripts/notes/constants.js b/app/assets/javascripts/notes/constants.js
new file mode 100644
index 00000000000..a6961063c01
--- /dev/null
+++ b/app/assets/javascripts/notes/constants.js
@@ -0,0 +1,11 @@
+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';
diff --git a/app/assets/javascripts/notes/event_hub.js b/app/assets/javascripts/notes/event_hub.js
new file mode 100644
index 00000000000..0948c2e5352
--- /dev/null
+++ b/app/assets/javascripts/notes/event_hub.js
@@ -0,0 +1,3 @@
+import Vue from 'vue';
+
+export default new Vue();
diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js
new file mode 100644
index 00000000000..e2ea37408cf
--- /dev/null
+++ b/app/assets/javascripts/notes/index.js
@@ -0,0 +1,35 @@
+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,
+ },
+ });
+ },
+}));
diff --git a/app/assets/javascripts/notes/mixins/autosave.js b/app/assets/javascripts/notes/mixins/autosave.js
new file mode 100644
index 00000000000..5843b97f225
--- /dev/null
+++ b/app/assets/javascripts/notes/mixins/autosave.js
@@ -0,0 +1,16 @@
+/* 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();
+ },
+ },
+};
diff --git a/app/assets/javascripts/notes/services/issue_notes_service.js b/app/assets/javascripts/notes/services/issue_notes_service.js
new file mode 100644
index 00000000000..b51b0cb2013
--- /dev/null
+++ b/app/assets/javascripts/notes/services/issue_notes_service.js
@@ -0,0 +1,35 @@
+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 });
+ },
+};
diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js
new file mode 100644
index 00000000000..13cd74bfa1c
--- /dev/null
+++ b/app/assets/javascripts/notes/stores/actions.js
@@ -0,0 +1,217 @@
+/* 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);
+ }
+};
diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js
new file mode 100644
index 00000000000..1f0c6af6156
--- /dev/null
+++ b/app/assets/javascripts/notes/stores/getters.js
@@ -0,0 +1,31 @@
+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));
diff --git a/app/assets/javascripts/notes/stores/index.js b/app/assets/javascripts/notes/stores/index.js
new file mode 100644
index 00000000000..8e0c8531bbc
--- /dev/null
+++ b/app/assets/javascripts/notes/stores/index.js
@@ -0,0 +1,23 @@
+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,
+});
diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js
new file mode 100644
index 00000000000..cd71533ba9d
--- /dev/null
+++ b/app/assets/javascripts/notes/stores/mutation_types.js
@@ -0,0 +1,14 @@
+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';
diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js
new file mode 100644
index 00000000000..3b2b2089d6e
--- /dev/null
+++ b/app/assets/javascripts/notes/stores/mutations.js
@@ -0,0 +1,151 @@
+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);
+ }
+ },
+};
diff --git a/app/assets/javascripts/notes/stores/utils.js b/app/assets/javascripts/notes/stores/utils.js
new file mode 100644
index 00000000000..6074115e855
--- /dev/null
+++ b/app/assets/javascripts/notes/stores/utils.js
@@ -0,0 +1,31 @@
+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();
+
diff --git a/app/assets/javascripts/shortcuts_issuable.js b/app/assets/javascripts/shortcuts_issuable.js
index 0be141eb5f9..78b257bf192 100644
--- a/app/assets/javascripts/shortcuts_issuable.js
+++ b/app/assets/javascripts/shortcuts_issuable.js
@@ -20,7 +20,7 @@ import './shortcuts_navigation';
Mousetrap.bind('m', this.openSidebarDropdown.bind(this, 'milestone'));
Mousetrap.bind('r', (function(_this) {
return function() {
- _this.replyWithSelectedText();
+ _this.replyWithSelectedText(isMergeRequest);
return false;
};
})(this));
@@ -38,9 +38,15 @@ import './shortcuts_navigation';
}
}
- ShortcutsIssuable.prototype.replyWithSelectedText = function() {
+ ShortcutsIssuable.prototype.replyWithSelectedText = function(isMergeRequest) {
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();
if (!documentFragment) {
@@ -57,6 +63,7 @@ import './shortcuts_navigation';
quote = _.map(selected.split("\n"), function(val) {
return ("> " + val).trim() + "\n";
});
+
// If replyField already has some content, add a newline before our quote
separator = replyField.val().trim() !== "" && "\n\n" || '';
replyField.val(function(a, current) {
@@ -64,7 +71,7 @@ import './shortcuts_navigation';
});
// Trigger autosave
- replyField.trigger('input');
+ replyField.trigger('input').trigger('change');
// Trigger autosize
var event = document.createEvent('Event');
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js
index 2d682215cf8..d32fe4abc7d 100644
--- a/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js
+++ b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js
@@ -6,6 +6,7 @@ import timeTracker from './time_tracker';
import Store from '../../stores/sidebar_store';
import Mediator from '../../sidebar_mediator';
+import eventHub from '../../event_hub';
export default {
data() {
@@ -20,6 +21,9 @@ export default {
methods: {
listenForQuickActions() {
$(document).on('ajax:success', '.gfm-form', this.quickActionListened);
+ eventHub.$on('timeTrackingUpdated', (data) => {
+ this.quickActionListened(null, data);
+ });
},
quickActionListened(e, data) {
const subscribedCommands = ['spend_time', 'time_estimate'];
diff --git a/app/assets/javascripts/vue_shared/components/issue/confidential_issue_warning.vue b/app/assets/javascripts/vue_shared/components/issue/confidential_issue_warning.vue
new file mode 100644
index 00000000000..397d16331d5
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/issue/confidential_issue_warning.vue
@@ -0,0 +1,16 @@
+<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>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
index 4e10bbc7408..759d30c9c7c 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/field.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -5,19 +5,30 @@
export default {
props: {
- markdownPreviewUrl: {
+ markdownPreviewPath: {
type: String,
required: false,
default: '',
},
- markdownDocs: {
+ markdownDocsPath: {
type: String,
required: true,
},
+ addSpacingClasses: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ quickActionsDocsPath: {
+ type: String,
+ required: false,
+ },
},
data() {
return {
markdownPreview: '',
+ referencedCommands: '',
+ referencedUsers: '',
markdownPreviewLoading: false,
previewMarkdown: false,
};
@@ -26,35 +37,48 @@
markdownHeader,
markdownToolbar,
},
+ computed: {
+ shouldShowReferencedUsers() {
+ const referencedUsersThreshold = 10;
+ return this.referencedUsers.length >= referencedUsersThreshold;
+ },
+ },
methods: {
toggleMarkdownPreview() {
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) {
this.markdownPreview = '';
- } else {
+ } else if (text) {
this.markdownPreviewLoading = true;
- this.$http.post(
- this.markdownPreviewUrl,
- {
- /*
- Can't use `$refs` as the component is technically in the parent component
- so we access the VNode & then get the element
- */
- text: this.$slots.textarea[0].elm.value,
- },
- )
- .then(resp => resp.json())
- .then((data) => {
- this.markdownPreviewLoading = false;
- this.markdownPreview = data.body;
+ this.$http.post(this.markdownPreviewPath, { text })
+ .then(resp => resp.json())
+ .then((data) => {
+ this.renderMarkdown(data);
+ })
+ .catch(() => new Flash('Error loading markdown preview'));
+ } else {
+ this.renderMarkdown();
+ }
+ },
+ renderMarkdown(data = {}) {
+ this.markdownPreviewLoading = false;
+ this.markdownPreview = data.body || 'Nothing to preview.';
- this.$nextTick(() => {
- $(this.$refs['markdown-preview']).renderGFM();
- });
- })
- .catch(() => new Flash('Error loading markdown preview'));
+ if (data.references) {
+ this.referencedCommands = data.references.commands;
+ this.referencedUsers = data.references.users;
}
+
+ this.$nextTick(() => {
+ $(this.$refs['markdown-preview']).renderGFM();
+ });
},
},
mounted() {
@@ -74,7 +98,8 @@
<template>
<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">
<markdown-header
:preview-markdown="previewMarkdown"
@@ -94,7 +119,9 @@
</i>
</a>
<markdown-toolbar
- :markdown-docs="markdownDocs" />
+ :markdown-docs-path="markdownDocsPath"
+ :quick-actions-docs-path="quickActionsDocsPath"
+ />
</div>
</div>
<div
@@ -108,5 +135,27 @@
Loading...
</span>
</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>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
index 93252293ba6..65fe7bbd94e 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue
@@ -1,10 +1,14 @@
<script>
export default {
props: {
- markdownDocs: {
+ markdownDocsPath: {
type: String,
required: true,
},
+ quickActionsDocsPath: {
+ type: String,
+ required: false,
+ },
},
};
</script>
@@ -12,22 +16,77 @@
<template>
<div class="comment-toolbar clearfix">
<div class="toolbar-text">
- <a
- :href="markdownDocs"
- target="_blank"
- tabindex="-1">
- Markdown is supported
- </a>
+ <template v-if="!quickActionsDocsPath && markdownDocsPath">
+ <a
+ :href="markdownDocsPath"
+ target="_blank"
+ tabindex="-1">
+ 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>
- <button
- class="toolbar-button markdown-selector"
- type="button"
- tabindex="-1">
- <i
- class="fa fa-file-image-o toolbar-button-icon"
- aria-hidden="true">
- </i>
- Attach a file
- </button>
+ <span class="uploading-container">
+ <span class="uploading-progress-container hide">
+ <i
+ class="fa fa-file-image-o toolbar-button-icon"
+ aria-hidden="true"></i>
+ <span class="attaching-file-message"></span>
+ <span class="uploading-progress">0%</span>
+ <span class="uploading-spinner">
+ <i
+ 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>
</template>
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index a49f9f99872..110b171676a 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -368,6 +368,10 @@
transform: translateY(0);
}
+.comment-type-dropdown.open .dropdown-menu {
+ display: block;
+}
+
.filtered-search-box-input-container {
.dropdown-menu,
.dropdown-menu-nav {
diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss
index ab5a901da71..4b0b238a767 100644
--- a/app/assets/stylesheets/pages/issuable.scss
+++ b/app/assets/stylesheets/pages/issuable.scss
@@ -498,6 +498,7 @@
color: $gray-darkest;
display: block;
margin: 16px 0 0;
+ font-size: 85%;
.author_link {
color: $gray-darkest;
diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss
index e2177f96aee..518bb270b88 100644
--- a/app/assets/stylesheets/pages/issues.scss
+++ b/app/assets/stylesheets/pages/issues.scss
@@ -250,6 +250,10 @@ ul.related-merge-requests > li {
}
}
+.discussion-reply-holder .note-edit-form {
+ display: block;
+}
+
@media (min-width: $screen-sm-min) {
.emoji-block .row {
display: flex;
diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss
index 9558924bbcb..8932cff22a8 100644
--- a/app/assets/stylesheets/pages/note_form.scss
+++ b/app/assets/stylesheets/pages/note_form.scss
@@ -20,10 +20,6 @@
}
}
-.new-note {
- display: none;
-}
-
.new-note,
.note-edit-form {
.note-form-actions {
@@ -202,6 +198,10 @@
.discussion-reply-holder {
background-color: $white-light;
padding: 10px 16px;
+
+ &.is-replying {
+ padding-bottom: $gl-padding;
+ }
}
}
diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss
index fbfe5d3c682..764984c5772 100644
--- a/app/assets/stylesheets/pages/notes.scss
+++ b/app/assets/stylesheets/pages/notes.scss
@@ -100,6 +100,20 @@ ul.notes {
}
}
+ .editing-spinner {
+ display: none;
+ }
+
+ &.is-requesting {
+ .note-timestamp {
+ display: none;
+ }
+
+ .editing-spinner {
+ display: inline-block;
+ }
+ }
+
&.is-editing {
.note-header,
.note-text,
@@ -402,6 +416,10 @@ ul.notes {
.note-header-info {
min-width: 0;
padding-bottom: 8px;
+
+ &.discussion {
+ padding-bottom: 0;
+ }
}
.system-note .note-header-info {
@@ -814,10 +832,6 @@ ul.notes {
}
}
-.discussion-notes .flash-container {
- margin-bottom: 0;
-}
-
// Merge request notes in diffs
.diff-file {
// Diff is inline
diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb
index af5f683bab5..18fd8eb114d 100644
--- a/app/controllers/concerns/notes_actions.rb
+++ b/app/controllers/concerns/notes_actions.rb
@@ -3,6 +3,7 @@ module NotesActions
extend ActiveSupport::Concern
included do
+ before_action :set_polling_interval_header, only: [:index]
before_action :authorize_admin_note!, only: [:update, :destroy]
before_action :note_project, only: [:create]
end
@@ -12,14 +13,18 @@ module NotesActions
notes_json = { notes: [], last_fetched_at: current_fetched_at }
- @notes = notes_finder.execute.inc_relations_for_view
- @notes = prepare_notes_for_rendering(@notes)
+ notes = notes_finder.execute
+ .inc_relations_for_view
+ .reject { |n| n.cross_reference_not_visible_for?(current_user) }
- @notes.each do |note|
- next if note.cross_reference_not_visible_for?(current_user)
+ notes = prepare_notes_for_rendering(notes)
- notes_json[:notes] << note_json(note)
- end
+ notes_json[:notes] =
+ if noteable.discussions_rendered_on_frontend?
+ note_serializer.represent(notes)
+ else
+ notes.map { |note| note_json(note) }
+ end
render json: notes_json
end
@@ -82,22 +87,27 @@ module NotesActions
}
if note.persisted?
- attrs.merge!(
- valid: true,
- id: note.id,
- discussion_id: note.discussion_id(noteable),
- html: note_html(note),
- note: note.note
- )
+ attrs[:valid] = true
- discussion = note.to_discussion(noteable)
- unless discussion.individual_note?
+ if noteable.nil? || noteable.discussions_rendered_on_frontend?
+ attrs.merge!(note_serializer.represent(note))
+ else
attrs.merge!(
- discussion_resolvable: discussion.resolvable?,
-
- diff_discussion_html: diff_discussion_html(discussion),
- discussion_html: discussion_html(discussion)
+ id: note.id,
+ discussion_id: note.discussion_id(noteable),
+ html: note_html(note),
+ 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
else
attrs.merge!(
@@ -168,6 +178,10 @@ module NotesActions
)
end
+ def set_polling_interval_header
+ Gitlab::PollingInterval.set_header(response, interval: 6_000)
+ end
+
def noteable
@noteable ||= notes_finder.target
end
@@ -180,6 +194,10 @@ module NotesActions
@notes_finder ||= NotesFinder.new(project, current_user, finder_params)
end
+ def note_serializer
+ NoteSerializer.new(project: project, noteable: noteable, current_user: current_user)
+ end
+
def note_project
return @note_project if defined?(@note_project)
return nil unless project
diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb
index 1afaceac567..349b19f72e2 100644
--- a/app/controllers/projects/issues_controller.rb
+++ b/app/controllers/projects/issues_controller.rb
@@ -91,11 +91,25 @@ class Projects::IssuesController < Projects::ApplicationController
respond_to do |format|
format.html
format.json do
- render json: IssueSerializer.new.represent(@issue)
+ render json: serializer.represent(@issue)
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
create_params = issue_params.merge(spammable_params).merge(
merge_request_to_resolve_discussions_of: params[:merge_request_to_resolve_discussions_of],
@@ -143,7 +157,7 @@ class Projects::IssuesController < Projects::ApplicationController
format.json do
if @issue.valid?
- render json: IssueSerializer.new.represent(@issue)
+ render json: serializer.represent(@issue)
else
render json: { errors: @issue.errors.full_messages }, status: :unprocessable_entity
end
@@ -287,4 +301,8 @@ class Projects::IssuesController < Projects::ApplicationController
redirect_to new_user_session_path, notice: notice
end
+
+ def serializer
+ IssueSerializer.new(current_user: current_user, project: issue.project)
+ end
end
diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb
index f4d452b3c9b..0fcd3347095 100644
--- a/app/helpers/issuables_helper.rb
+++ b/app/helpers/issuables_helper.rb
@@ -35,7 +35,7 @@ module IssuablesHelper
def serialize_issuable(issuable)
case issuable
when Issue
- IssueSerializer.new.represent(issuable).to_json
+ IssueSerializer.new(current_user: current_user, project: issuable.project).represent(issuable).to_json
when MergeRequest
MergeRequestSerializer
.new(current_user: current_user, project: issuable.project)
@@ -210,9 +210,9 @@ module IssuablesHelper
canMove: current_user ? issuable.can_move?(current_user) : false,
issuableRef: issuable.to_reference,
isConfidential: issuable.confidential,
- markdownPreviewUrl: preview_markdown_path(@project),
- markdownDocs: help_page_path('user/markdown'),
- projectsAutocompleteUrl: autocomplete_projects_path(project_id: @project.id),
+ markdownPreviewPath: preview_markdown_path(@project),
+ markdownDocsPath: help_page_path('user/markdown'),
+ projectsAutocompletePath: autocomplete_projects_path(project_id: @project.id),
issuableTemplates: issuable_templates(issuable),
projectPath: ref_project.path,
projectNamespace: ref_project.namespace.full_path,
diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb
index 7e1ccb23e9e..853ce827061 100644
--- a/app/helpers/issues_helper.rb
+++ b/app/helpers/issues_helper.rb
@@ -137,7 +137,7 @@ module IssuesHelper
end
def awards_sort(awards)
- awards.sort_by do |award, notes|
+ awards.sort_by do |award, award_emojis|
if award == "thumbsup"
0
elsif award == "thumbsdown"
diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb
index e857e837c16..8c5e258f519 100644
--- a/app/helpers/notes_helper.rb
+++ b/app/helpers/notes_helper.rb
@@ -93,11 +93,13 @@ module NotesHelper
end
end
- def notes_url
+ def notes_url(params = {})
if @snippet.is_a?(PersonalSnippet)
- snippet_notes_path(@snippet)
+ snippet_notes_path(@snippet, params)
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
diff --git a/app/helpers/system_note_helper.rb b/app/helpers/system_note_helper.rb
index 08fd97cd048..c98f65c7644 100644
--- a/app/helpers/system_note_helper.rb
+++ b/app/helpers/system_note_helper.rb
@@ -22,8 +22,14 @@ module SystemNoteHelper
'duplicate' => 'icon_clone'
}.freeze
+ def system_note_icon_name(note)
+ ICON_NAMES_BY_ACTION[note.system_note_metadata&.action]
+ end
+
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
end
+
+ extend self
end
diff --git a/app/models/award_emoji.rb b/app/models/award_emoji.rb
index 91b62dabbcd..4d1a15c53aa 100644
--- a/app/models/award_emoji.rb
+++ b/app/models/award_emoji.rb
@@ -17,6 +17,9 @@ class AwardEmoji < ActiveRecord::Base
scope :downvotes, -> { where(name: DOWNVOTE_NAME) }
scope :upvotes, -> { where(name: UPVOTE_NAME) }
+ after_save :expire_etag_cache
+ after_destroy :expire_etag_cache
+
class << self
def votes_for_collection(ids, type)
select('name', 'awardable_id', 'COUNT(*) as count')
@@ -32,4 +35,8 @@ class AwardEmoji < ActiveRecord::Base
def upvote?
self.name == UPVOTE_NAME
end
+
+ def expire_etag_cache
+ awardable.try(:expire_etag_cache)
+ end
end
diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb
index c7bdc997eca..1c4ddabcad5 100644
--- a/app/models/concerns/noteable.rb
+++ b/app/models/concerns/noteable.rb
@@ -24,6 +24,10 @@ module Noteable
DiscussionNote::NOTEABLE_TYPES.include?(base_class_name)
end
+ def discussions_rendered_on_frontend?
+ false
+ end
+
def discussion_notes
notes
end
@@ -38,7 +42,7 @@ module Noteable
def grouped_diff_discussions(*args)
# 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)
end
diff --git a/app/models/discussion.rb b/app/models/discussion.rb
index d1cec7613af..b80da7b246a 100644
--- a/app/models/discussion.rb
+++ b/app/models/discussion.rb
@@ -81,6 +81,10 @@ class Discussion
last_note.author
end
+ def updated?
+ last_updated_at != created_at
+ end
+
def id
first_note.discussion_id(context_noteable)
end
diff --git a/app/models/issue.rb b/app/models/issue.rb
index dfcd4030ec3..8c7d492e605 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -269,6 +269,10 @@ class Issue < ActiveRecord::Base
end
end
+ def discussions_rendered_on_frontend?
+ true
+ end
+
def update_project_counter_caches?
state_changed? || confidential_changed?
end
diff --git a/app/models/note.rb b/app/models/note.rb
index a752c897d63..1073c115630 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -299,6 +299,17 @@ class Note < ActiveRecord::Base
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
def keep_around_commit
@@ -326,15 +337,4 @@ class Note < ActiveRecord::Base
def set_discussion_id
self.discussion_id ||= discussion_class.discussion_id(self)
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
diff --git a/app/serializers/award_emoji_entity.rb b/app/serializers/award_emoji_entity.rb
new file mode 100644
index 00000000000..6e03cd02392
--- /dev/null
+++ b/app/serializers/award_emoji_entity.rb
@@ -0,0 +1,4 @@
+class AwardEmojiEntity < Grape::Entity
+ expose :name
+ expose :user, using: API::Entities::UserSafe
+end
diff --git a/app/serializers/discussion_entity.rb b/app/serializers/discussion_entity.rb
new file mode 100644
index 00000000000..0a92e3f8167
--- /dev/null
+++ b/app/serializers/discussion_entity.rb
@@ -0,0 +1,10 @@
+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
diff --git a/app/serializers/discussion_serializer.rb b/app/serializers/discussion_serializer.rb
new file mode 100644
index 00000000000..ed5e1224bb2
--- /dev/null
+++ b/app/serializers/discussion_serializer.rb
@@ -0,0 +1,3 @@
+class DiscussionSerializer < BaseSerializer
+ entity DiscussionEntity
+end
diff --git a/app/serializers/issuable_entity.rb b/app/serializers/issuable_entity.rb
index bd5211b8e58..61c7a428745 100644
--- a/app/serializers/issuable_entity.rb
+++ b/app/serializers/issuable_entity.rb
@@ -15,4 +15,6 @@ class IssuableEntity < Grape::Entity
expose :total_time_spent
expose :human_time_estimate
expose :human_total_time_spent
+ expose :milestone, using: API::Entities::Milestone
+ expose :labels, using: LabelEntity
end
diff --git a/app/serializers/issue_entity.rb b/app/serializers/issue_entity.rb
index c189a4992da..0d6feb78173 100644
--- a/app/serializers/issue_entity.rb
+++ b/app/serializers/issue_entity.rb
@@ -7,10 +7,26 @@ class IssueEntity < IssuableEntity
expose :due_date
expose :moved_to_id
expose :project_id
- expose :milestone, using: API::Entities::Milestone
- expose :labels, using: LabelEntity
expose :web_url do |issue|
project_issue_path(issue.project, issue)
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
diff --git a/app/serializers/note_attachment_entity.rb b/app/serializers/note_attachment_entity.rb
new file mode 100644
index 00000000000..1ad50568ab9
--- /dev/null
+++ b/app/serializers/note_attachment_entity.rb
@@ -0,0 +1,5 @@
+class NoteAttachmentEntity < Grape::Entity
+ expose :url
+ expose :filename
+ expose :image?, as: :image
+end
diff --git a/app/serializers/note_entity.rb b/app/serializers/note_entity.rb
new file mode 100644
index 00000000000..7d50e0ff10d
--- /dev/null
+++ b/app/serializers/note_entity.rb
@@ -0,0 +1,60 @@
+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
diff --git a/app/serializers/note_serializer.rb b/app/serializers/note_serializer.rb
new file mode 100644
index 00000000000..2afe40d7a34
--- /dev/null
+++ b/app/serializers/note_serializer.rb
@@ -0,0 +1,3 @@
+class NoteSerializer < BaseSerializer
+ entity NoteEntity
+end
diff --git a/app/serializers/note_user_entity.rb b/app/serializers/note_user_entity.rb
new file mode 100644
index 00000000000..7289f3a0222
--- /dev/null
+++ b/app/serializers/note_user_entity.rb
@@ -0,0 +1,3 @@
+class NoteUserEntity < UserEntity
+ unexpose :web_url
+end
diff --git a/app/serializers/user_serializer.rb b/app/serializers/user_serializer.rb
new file mode 100644
index 00000000000..49a71ebac61
--- /dev/null
+++ b/app/serializers/user_serializer.rb
@@ -0,0 +1,3 @@
+class UserSerializer < BaseSerializer
+ entity UserEntity
+end
diff --git a/app/views/discussions/_headline.html.haml b/app/views/discussions/_headline.html.haml
index c1dabeed387..25e90924413 100644
--- a/app/views/discussions/_headline.html.haml
+++ b/app/views/discussions/_headline.html.haml
@@ -5,7 +5,7 @@
by
= link_to_member(@project, discussion.resolved_by, avatar: false)
= 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
Last updated
- if discussion.last_updated_by
diff --git a/app/views/projects/_md_preview.html.haml b/app/views/projects/_md_preview.html.haml
index 97041b87c48..71424593f2e 100644
--- a/app/views/projects/_md_preview.html.haml
+++ b/app/views/projects/_md_preview.html.haml
@@ -1,10 +1,5 @@
- 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-header
%ul.nav-links.clearfix
diff --git a/app/views/projects/issues/_discussion.html.haml b/app/views/projects/issues/_discussion.html.haml
index 8b095f4ca10..483f28c74f2 100644
--- a/app/views/projects/issues/_discussion.html.haml
+++ b/app/views/projects/issues/_discussion.html.haml
@@ -1,7 +1,17 @@
+- @gfm_form = true
+
- content_for :note_actions do
- 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 '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
- = render 'shared/notes/notes_with_form', :autocomplete => true
+%section.js-vue-notes-event
+ #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 } }
diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml
index de0f1de057d..fd7ff176c5e 100644
--- a/app/views/projects/issues/show.html.haml
+++ b/app/views/projects/issues/show.html.haml
@@ -2,6 +2,11 @@
- page_title "#{@issue.title} (#{@issue.to_reference})", "Issues"
- page_description @issue.description
- 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_report_spam = @issue.submittable_as_spam_by?(current_user)
@@ -23,7 +28,7 @@
= icon('eye-slash', class: 'is-confidential')
= issuable_meta(@issue, @project, "Issue")
- .issuable-actions
+ .issuable-actions.js-issuable-actions
.clearfix.issue-btn-group.dropdown
%button.btn.btn-default.pull-left.hidden-md.hidden-lg{ type: "button", data: { toggle: "dropdown" } }
Options
@@ -36,8 +41,8 @@
- 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))
- 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 '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 '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 js-btn-issue-action #{issue_button_visibility(@issue, false)}", title: 'Reopen issue'
- 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'
- if can_update_issue || can_report_spam
@@ -74,7 +79,7 @@
.content-block.emoji-block
.row
- .col-sm-8
+ .col-sm-8.js-issue-note-awards
= render 'award_emoji/awards_block', awardable: @issue, inline: true
.col-sm-4.new-branch-col
= render 'new_branch' unless @issue.confidential?
diff --git a/app/views/projects/merge_requests/_mr_title.html.haml b/app/views/projects/merge_requests/_mr_title.html.haml
index a2e819fb3a7..f3c44c94a5c 100644
--- a/app/views/projects/merge_requests/_mr_title.html.haml
+++ b/app/views/projects/merge_requests/_mr_title.html.haml
@@ -17,7 +17,7 @@
.issuable-meta
= issuable_meta(@merge_request, @project, "Merge request")
- .issuable-actions
+ .issuable-actions.js-issuable-actions
.clearfix.issue-btn-group.dropdown
%button.btn.btn-default.pull-left.hidden-md.hidden-lg{ type: "button", data: { toggle: "dropdown" } }
Options
diff --git a/app/views/shared/issuable/_close_reopen_button.html.haml b/app/views/shared/issuable/_close_reopen_button.html.haml
index f22b6c9a6c2..cb706d80f23 100644
--- a/app/views/shared/issuable/_close_reopen_button.html.haml
+++ b/app/views/shared/issuable/_close_reopen_button.html.haml
@@ -4,9 +4,9 @@
- if can_update && is_current_user
= 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,
- 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
= render 'shared/issuable/close_reopen_report_toggle', issuable: issuable
- elsif issuable.author
diff --git a/app/views/shared/issuable/_close_reopen_report_toggle.html.haml b/app/views/shared/issuable/_close_reopen_report_toggle.html.haml
index daa05990ae9..d8144a39b23 100644
--- a/app/views/shared/issuable/_close_reopen_report_toggle.html.haml
+++ b/app/views/shared/issuable/_close_reopen_report_toggle.html.haml
@@ -2,7 +2,7 @@
- button_action = issuable.closed? ? 'reopen' : 'close'
- display_button_action = button_action.capitalize
- 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"
- button_method = issuable_close_reopen_button_method(issuable)
diff --git a/app/views/shared/notes/_notes_with_form.html.haml b/app/views/shared/notes/_notes_with_form.html.haml
index eae04c9bbb8..e3e86709b8f 100644
--- a/app/views/shared/notes/_notes_with_form.html.haml
+++ b/app/views/shared/notes/_notes_with_form.html.haml
@@ -17,9 +17,9 @@
- elsif !current_user
.disabled-comment.text-center.prepend-top-default
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
- = 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
%script.js-notes-data{ type: "application/json" }= initial_notes_data(autocomplete).to_json.html_safe
diff --git a/config/routes/project.rb b/config/routes/project.rb
index 06928c7b9ce..c703a7294ed 100644
--- a/config/routes/project.rb
+++ b/config/routes/project.rb
@@ -308,6 +308,7 @@ constraints(ProjectUrlConstrainer.new) do
get :can_create_branch
get :realtime_changes
post :create_merge_request
+ get :discussions, format: :json
end
collection do
post :bulk_update
diff --git a/config/webpack.config.js b/config/webpack.config.js
index 7d63a42d7d8..ad88e48550d 100644
--- a/config/webpack.config.js
+++ b/config/webpack.config.js
@@ -55,6 +55,7 @@ var config = {
monitoring: './monitoring/monitoring_bundle.js',
network: './network/network_bundle.js',
notebook_viewer: './blob/notebook_viewer.js',
+ notes: './notes/index.js',
pdf_viewer: './blob/pdf_viewer.js',
pipelines: './pipelines/pipelines_bundle.js',
pipelines_charts: './pipelines/pipelines_charts.js',
@@ -194,6 +195,7 @@ var config = {
'merge_conflicts',
'monitoring',
'notebook_viewer',
+ 'notes',
'pdf_viewer',
'pipelines',
'pipelines_details',
diff --git a/features/steps/project/issues/issues.rb b/features/steps/project/issues/issues.rb
index 43cdb4121c4..f7dd4fc21e9 100644
--- a/features/steps/project/issues/issues.rb
+++ b/features/steps/project/issues/issues.rb
@@ -168,6 +168,7 @@ class Spinach::Features::ProjectIssues < Spinach::FeatureSteps
author: project.users.first,
description: "# Description header"
)
+ wait_for_requests
end
step 'project "Shop" have "Tweet control" open issue' do
diff --git a/features/steps/shared/note.rb b/features/steps/shared/note.rb
index 492da38355c..0cd7b506a95 100644
--- a/features/steps/shared/note.rb
+++ b/features/steps/shared/note.rb
@@ -137,7 +137,7 @@ module SharedNote
step 'The comment with the header should not have an ID' 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")
end
end
@@ -150,15 +150,20 @@ module SharedNote
note.find('.js-note-edit').click
end
+ page.find('.current-note-edit-form textarea')
+
page.within(".current-note-edit-form") do
fill_in 'note[note]', with: '+1 Awesome!'
click_button 'Save comment'
end
+ wait_for_requests
end
step 'I should see +1 in the description' do
page.within(".note") do
expect(page).to have_content("+1 Awesome!")
end
+
+ wait_for_requests
end
end
diff --git a/lib/api/entities.rb b/lib/api/entities.rb
index 803b48dd88a..9df9a515990 100644
--- a/lib/api/entities.rb
+++ b/lib/api/entities.rb
@@ -1,11 +1,11 @@
module API
module Entities
class UserSafe < Grape::Entity
- expose :name, :username
+ expose :id, :name, :username
end
class UserBasic < UserSafe
- expose :id, :state
+ expose :state
expose :avatar_url do |user, options|
user.avatar_url(only_path: false)
end
diff --git a/package.json b/package.json
index 99704c07849..feae6ca9748 100644
--- a/package.json
+++ b/package.json
@@ -64,6 +64,7 @@
"vue-loader": "^11.3.4",
"vue-resource": "^1.3.4",
"vue-template-compiler": "^2.2.6",
+ "vuex": "^2.3.1",
"webpack": "^3.5.5",
"webpack-bundle-analyzer": "^2.8.2",
"webpack-stats-plugin": "^0.1.5"
diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb
index da8f9e8376e..65f4d09cfce 100644
--- a/spec/controllers/projects/issues_controller_spec.rb
+++ b/spec/controllers/projects/issues_controller_spec.rb
@@ -879,4 +879,19 @@ describe Projects::IssuesController do
format: :json
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
diff --git a/spec/controllers/projects/notes_controller_spec.rb b/spec/controllers/projects/notes_controller_spec.rb
index f280c55059c..6ffe41b8608 100644
--- a/spec/controllers/projects/notes_controller_spec.rb
+++ b/spec/controllers/projects/notes_controller_spec.rb
@@ -46,10 +46,13 @@ describe Projects::NotesController do
end
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
- get :index, request_params
+ get :index, params
expect(note_json[:id]).to eq(note.id)
expect(note_json[:discussion_html]).not_to be_nil
@@ -104,10 +107,12 @@ describe Projects::NotesController do
end
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
- get :index, request_params
+ get :index, params
expect(note_json[:id]).to eq(note.id)
expect(note_json[:html]).not_to be_nil
@@ -125,7 +130,9 @@ describe Projects::NotesController do
note: { note: 'some note', noteable_id: merge_request.id, noteable_type: 'MergeRequest' },
namespace_id: project.namespace,
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
diff --git a/spec/features/issues/award_emoji_spec.rb b/spec/features/issues/award_emoji_spec.rb
index 134e618feac..a29acb30163 100644
--- a/spec/features/issues/award_emoji_spec.rb
+++ b/spec/features/issues/award_emoji_spec.rb
@@ -70,13 +70,13 @@ describe 'Awards Emoji' do
it 'toggles the smiley emoji on a note', js: true do
toggle_smiley_emoji(true)
- within('.note-awards') do
+ within('.note-body') do
expect(find(emoji_counter)).to have_text("1")
end
toggle_smiley_emoji(false)
- within('.note-awards') do
+ within('.note-body') do
expect(page).not_to have_selector(emoji_counter)
end
end
diff --git a/spec/features/issues/gfm_autocomplete_spec.rb b/spec/features/issues/gfm_autocomplete_spec.rb
index b84635c5134..c6cf6265645 100644
--- a/spec/features/issues/gfm_autocomplete_spec.rb
+++ b/spec/features/issues/gfm_autocomplete_spec.rb
@@ -28,8 +28,8 @@ feature 'GFM autocomplete', js: true do
it 'opens autocomplete menu when field starts with text' do
page.within '.timeline-content-form' do
- find('#note_note').native.send_keys('')
- find('#note_note').native.send_keys('@')
+ find('#note-body').native.send_keys('')
+ find('#note-body').native.send_keys('@')
end
expect(page).to have_selector('.atwho-container')
@@ -37,8 +37,8 @@ feature 'GFM autocomplete', js: true do
it 'doesnt open autocomplete menu character is prefixed with text' do
page.within '.timeline-content-form' do
- find('#note_note').native.send_keys('testing')
- find('#note_note').native.send_keys('@')
+ find('#note-body').native.send_keys('testing')
+ find('#note-body').native.send_keys('@')
end
expect(page).not_to have_selector('.atwho-view')
@@ -46,8 +46,8 @@ feature 'GFM autocomplete', js: true do
it 'doesnt select the first item for non-assignee dropdowns' do
page.within '.timeline-content-form' do
- find('#note_note').native.send_keys('')
- find('#note_note').native.send_keys(':')
+ find('#note-body').native.send_keys('')
+ find('#note-body').native.send_keys(':')
end
expect(page).to have_selector('.atwho-container')
@@ -58,7 +58,7 @@ feature 'GFM autocomplete', js: true do
end
it 'does not open autocomplete menu when ":" is prefixed by a number and letters' do
- note = find('#note_note')
+ note = find('#note-body')
# Number.
page.within '.timeline-content-form' do
@@ -86,8 +86,8 @@ feature 'GFM autocomplete', js: true do
it 'selects the first item for assignee dropdowns' do
page.within '.timeline-content-form' do
- find('#note_note').native.send_keys('')
- find('#note_note').native.send_keys('@')
+ find('#note-body').native.send_keys('')
+ find('#note-body').native.send_keys('@')
end
expect(page).to have_selector('.atwho-container')
@@ -99,8 +99,8 @@ feature 'GFM autocomplete', js: true do
it 'includes items for assignee dropdowns with non-ASCII characters in name' do
page.within '.timeline-content-form' do
- find('#note_note').native.send_keys('')
- find('#note_note').native.send_keys("@#{user.name[0...8]}")
+ find('#note-body').native.send_keys('')
+ find('#note-body').native.send_keys("@#{user.name[0...8]}")
end
expect(page).to have_selector('.atwho-container')
@@ -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
page.within '.timeline-content-form' do
- find('#note_note').native.send_keys('')
- find('#note_note').native.send_keys(':1')
+ find('#note-body').native.send_keys('')
+ find('#note-body').native.send_keys(':1')
end
expect(page).to have_selector('.atwho-container')
@@ -125,7 +125,7 @@ feature 'GFM autocomplete', js: true do
context 'if a selected value has special characters' do
it 'wraps the result in double quotes' do
- note = find('#note_note')
+ note = find('#note-body')
page.within '.timeline-content-form' do
note.native.send_keys('')
note.native.send_keys("~#{label.title[0]}")
@@ -138,7 +138,7 @@ feature 'GFM autocomplete', js: true do
end
it "shows dropdown after a new line" do
- note = find('#note_note')
+ note = find('#note-body')
page.within '.timeline-content-form' do
note.native.send_keys('test')
note.native.send_keys(:enter)
@@ -150,7 +150,7 @@ feature 'GFM autocomplete', js: true do
end
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
note.native.send_keys('')
note.native.send_keys("@")
@@ -168,7 +168,7 @@ feature 'GFM autocomplete', js: true do
end
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
note.native.send_keys('')
note.native.send_keys('~')
@@ -179,7 +179,7 @@ feature 'GFM autocomplete', js: true do
end
it 'doesn\'t wrap for assignee values' do
- note = find('#note_note')
+ note = find('#note-body')
page.within '.timeline-content-form' do
note.native.send_keys('')
note.native.send_keys("@#{user.username[0]}")
@@ -192,7 +192,7 @@ feature 'GFM autocomplete', js: true do
end
it 'doesn\'t wrap for emoji values' do
- note = find('#note_note')
+ note = find('#note-body')
page.within '.timeline-content-form' do
note.native.send_keys('')
note.native.send_keys(":cartwheel")
@@ -206,7 +206,7 @@ feature 'GFM autocomplete', js: true do
it 'doesn\'t open autocomplete after non-word character' 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
expect(page).not_to have_selector('.atwho-view')
@@ -214,14 +214,14 @@ feature 'GFM autocomplete', js: true do
it 'doesn\'t open autocomplete if there is no space before' 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
expect(page).not_to have_selector('.atwho-view')
end
it 'triggers autocomplete after selecting a quick action' do
- note = find('#note_note')
+ note = find('#note-body')
page.within '.timeline-content-form' do
note.native.send_keys('')
note.native.send_keys('/as')
diff --git a/spec/features/issues/markdown_toolbar_spec.rb b/spec/features/issues/markdown_toolbar_spec.rb
index 8c23fcd483b..634ea111dc1 100644
--- a/spec/features/issues/markdown_toolbar_spec.rb
+++ b/spec/features/issues/markdown_toolbar_spec.rb
@@ -12,26 +12,26 @@ feature 'Issue markdown toolbar', js: true do
end
it "doesn't include first new line when adding bold" do
- find('#note_note').native.send_keys('test')
- find('#note_note').native.send_key(:enter)
- find('#note_note').native.send_keys('bold')
+ find('#note-body').native.send_keys('test')
+ find('#note-body').native.send_key(:enter)
+ 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
- expect(find('#note_note')[:value]).to eq("test\n**bold**\n")
+ expect(find('#note-body')[:value]).to eq("test\n**bold**\n")
end
it "doesn't include first new line when adding underline" do
- find('#note_note').native.send_keys('test')
- find('#note_note').native.send_key(:enter)
- find('#note_note').native.send_keys('underline')
+ find('#note-body').native.send_keys('test')
+ find('#note-body').native.send_key(:enter)
+ 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
- expect(find('#note_note')[:value]).to eq("test\n*underline*\n")
+ expect(find('#note-body')[:value]).to eq("test\n*underline*\n")
end
end
diff --git a/spec/features/issues/note_polling_spec.rb b/spec/features/issues/note_polling_spec.rb
index 62dbc3efb01..793572851da 100644
--- a/spec/features/issues/note_polling_spec.rb
+++ b/spec/features/issues/note_polling_spec.rb
@@ -13,7 +13,7 @@ feature 'Issue notes polling', :js do
it 'displays the new comment' do
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!')
end
@@ -31,16 +31,6 @@ feature 'Issue notes polling', :js do
visit project_issue_path(project, issue)
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
expect(page).to have_selector("#note_#{existing_note.id}", text: note_text)
@@ -49,24 +39,14 @@ feature 'Issue notes polling', :js do
expect(page).to have_selector("#note_#{existing_note.id}", text: updated_text)
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)
expect(page).to have_field("note[note]", with: note_text)
update_note(existing_note, updated_text)
- expect(page).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).not_to have_field("note[note]", with: updated_text)
expect(page).to have_selector(".alert")
end
@@ -75,8 +55,6 @@ feature 'Issue notes polling', :js do
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)
find("#note_#{existing_note.id} .note-edit-cancel").click
@@ -97,14 +75,12 @@ feature 'Issue notes polling', :js do
visit project_issue_path(project, issue)
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} .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
end
@@ -118,16 +94,15 @@ feature 'Issue notes polling', :js do
visit project_issue_path(project, issue)
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} .original-note-content", count: 1, visible: false)
end
end
end
def update_note(note, new_text)
note.update(note: new_text)
- page.execute_script('notes.refresh();')
+ wait_for_requests
end
def click_edit_action(note)
diff --git a/spec/features/participants_autocomplete_spec.rb b/spec/features/participants_autocomplete_spec.rb
index a22d548eef3..96f6df587e1 100644
--- a/spec/features/participants_autocomplete_spec.rb
+++ b/spec/features/participants_autocomplete_spec.rb
@@ -11,10 +11,14 @@ feature 'Member autocomplete', :js do
sign_in(user)
end
- shared_examples "open suggestions when typing @" do
+ shared_examples "open suggestions when typing @" do |resource_name|
before 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
@@ -32,7 +36,7 @@ feature 'Member autocomplete', :js do
visit project_issue_path(project, noteable)
end
- include_examples "open suggestions when typing @"
+ include_examples "open suggestions when typing @", 'issue'
end
context 'adding a new note on a Merge Request' do
@@ -45,7 +49,7 @@ feature 'Member autocomplete', :js do
visit project_merge_request_path(project, noteable)
end
- include_examples "open suggestions when typing @"
+ include_examples "open suggestions when typing @", 'merge_request'
end
context 'adding a new note on a Commit' do
@@ -60,6 +64,6 @@ feature 'Member autocomplete', :js do
visit project_commit_path(project, noteable)
end
- include_examples "open suggestions when typing @"
+ include_examples "open suggestions when typing @", 'commit'
end
end
diff --git a/spec/features/reportable_note/commit_spec.rb b/spec/features/reportable_note/commit_spec.rb
index 3bf25221e36..9b6864eb90f 100644
--- a/spec/features/reportable_note/commit_spec.rb
+++ b/spec/features/reportable_note/commit_spec.rb
@@ -18,7 +18,7 @@ describe 'Reportable note on commit', :js do
visit project_commit_path(project, sample_commit.id)
end
- it_behaves_like 'reportable note'
+ it_behaves_like 'reportable note', 'commit'
end
context 'a diff note' do
@@ -28,6 +28,6 @@ describe 'Reportable note on commit', :js do
visit project_commit_path(project, sample_commit.id)
end
- it_behaves_like 'reportable note'
+ it_behaves_like 'reportable note', 'commit'
end
end
diff --git a/spec/features/reportable_note/issue_spec.rb b/spec/features/reportable_note/issue_spec.rb
index 21e96f6f103..f5a1950e48e 100644
--- a/spec/features/reportable_note/issue_spec.rb
+++ b/spec/features/reportable_note/issue_spec.rb
@@ -13,5 +13,5 @@ describe 'Reportable note on issue', :js do
visit project_issue_path(project, issue)
end
- it_behaves_like 'reportable note'
+ it_behaves_like 'reportable note', 'issue'
end
diff --git a/spec/features/reportable_note/merge_request_spec.rb b/spec/features/reportable_note/merge_request_spec.rb
index bb296546e06..1f69257f7ed 100644
--- a/spec/features/reportable_note/merge_request_spec.rb
+++ b/spec/features/reportable_note/merge_request_spec.rb
@@ -15,12 +15,12 @@ describe 'Reportable note on merge request', :js do
context 'a normal note' do
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
context 'a diff note' do
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
diff --git a/spec/features/reportable_note/snippets_spec.rb b/spec/features/reportable_note/snippets_spec.rb
index f1e48ed46be..98ef50b78de 100644
--- a/spec/features/reportable_note/snippets_spec.rb
+++ b/spec/features/reportable_note/snippets_spec.rb
@@ -17,6 +17,6 @@ describe 'Reportable note on snippets', :js do
visit project_snippet_path(project, snippet)
end
- it_behaves_like 'reportable note'
+ it_behaves_like 'reportable note', 'snippet'
end
end
diff --git a/spec/features/task_lists_spec.rb b/spec/features/task_lists_spec.rb
index 580258f77eb..ff6f71d7528 100644
--- a/spec/features/task_lists_spec.rb
+++ b/spec/features/task_lists_spec.rb
@@ -181,7 +181,7 @@ feature 'Task Lists' do
project: project, author: user)
end
- it 'renders for note body' do
+ it 'renders for note body', :js do
visit_issue(project, issue)
expect(page).to have_selector('.note ul.task-list', count: 1)
@@ -189,15 +189,14 @@ feature 'Task Lists' do
expect(page).to have_selector('.note ul input[checked]', count: 2)
end
- it 'contains the required selectors' do
+ it 'contains the required selectors', :js do
visit_issue(project, issue)
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 .js-task-list-field')
end
- it 'is only editable by author' do
+ it 'is only editable by author', :js do
visit_issue(project, issue)
expect(page).to have_selector('.js-task-list-container')
@@ -215,7 +214,7 @@ feature 'Task Lists' do
project: project, author: user)
end
- it 'renders for note body' do
+ it 'renders for note body', :js do
visit_issue(project, issue)
expect(page).to have_selector('.note ul.task-list', count: 1)
@@ -230,7 +229,7 @@ feature 'Task Lists' do
project: project, author: user)
end
- it 'renders for note body' do
+ it 'renders for note body', :js do
visit_issue(project, issue)
expect(page).to have_selector('.note ul.task-list', count: 1)
diff --git a/spec/features/uploads/user_uploads_file_to_note_spec.rb b/spec/features/uploads/user_uploads_file_to_note_spec.rb
index 53cad623a35..e1c95590af1 100644
--- a/spec/features/uploads/user_uploads_file_to_note_spec.rb
+++ b/spec/features/uploads/user_uploads_file_to_note_spec.rb
@@ -10,6 +10,7 @@ feature 'User uploads file to note' do
before do
sign_in(user)
visit project_issue_path(project, issue)
+ wait_for_requests
end
context 'before uploading' do
diff --git a/spec/fixtures/api/schemas/entities/merge_request.json b/spec/fixtures/api/schemas/entities/merge_request.json
index 2f12b671dec..1030f323a1f 100644
--- a/spec/fixtures/api/schemas/entities/merge_request.json
+++ b/spec/fixtures/api/schemas/entities/merge_request.json
@@ -18,6 +18,8 @@
"total_time_spent": { "type": "integer" },
"human_time_estimate": { "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"] },
"merge_error": { "type": ["string", "null"] },
"merge_commit_sha": { "type": ["string", "null"] },
diff --git a/spec/javascripts/awards_handler_spec.js b/spec/javascripts/awards_handler_spec.js
index 8e056882108..a22b71fd1dc 100644
--- a/spec/javascripts/awards_handler_spec.js
+++ b/spec/javascripts/awards_handler_spec.js
@@ -25,9 +25,10 @@ import '~/lib/utils/common_utils';
};
describe('AwardsHandler', function() {
- preloadFixtures('issues/issue_with_comment.html.raw');
+ preloadFixtures('merge_requests/diff_comment.html.raw');
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) => {
awardsHandler = obj;
spyOn(awardsHandler, 'postEmoji').and.callFake((button, url, emoji, cb) => cb());
@@ -139,7 +140,7 @@ import '~/lib/utils/common_utils';
});
describe('::getAwardUrl', 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() {
diff --git a/spec/javascripts/behaviors/quick_submit_spec.js b/spec/javascripts/behaviors/quick_submit_spec.js
index 6dc48f9a293..f62bf43adb9 100644
--- a/spec/javascripts/behaviors/quick_submit_spec.js
+++ b/spec/javascripts/behaviors/quick_submit_spec.js
@@ -1,119 +1,111 @@
-/* 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';
-(function() {
- describe('Quick Submit behavior', function() {
- 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')
- };
+describe('Quick Submit behavior', () => {
+ const keydownEvent = (options = { keyCode: 13, metaKey: true }) => $.Event('keydown', options);
- this.textarea = $('.js-quick-submit textarea').first();
- });
- 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());
+ preloadFixtures('merge_requests/merge_request_with_task_list.html.raw');
- 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() {
- const submitButton = $('.js-quick-submit input[type=submit]');
- this.textarea.trigger(keydownEvent());
+ this.spies = {
+ submit: spyOnEvent('form', 'submit'),
+ };
- expect(submitButton).toBeDisabled();
- });
- 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);
+ this.textarea = $('.js-quick-submit textarea').first();
+ });
- const oldClick = spyOnEvent(existingSubmit, 'click');
- const newClick = spyOnEvent(newSubmit, 'click');
+ it('does not respond to other keyCodes', () => {
+ 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();
- 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/)) {
- it('responds to Meta+Enter', function() {
- this.textarea.trigger(keydownEvent());
- return expect(this.spies.submit).toHaveBeenTriggered();
- });
- it('excludes other modifier keys', function() {
- this.textarea.trigger(keydownEvent({
- altKey: true
- }));
- this.textarea.trigger(keydownEvent({
- ctrlKey: true
- }));
- this.textarea.trigger(keydownEvent({
- shiftKey: true
- }));
- return expect(this.spies.submit).not.toHaveBeenTriggered();
- });
- } else {
- it('responds to Ctrl+Enter', function() {
+ it('does not respond to repeated events', () => {
+ this.textarea.trigger(keydownEvent({
+ repeat: true,
+ }));
+ expect(this.spies.submit).not.toHaveBeenTriggered();
+ });
+
+ it('disables input of type submit', () => {
+ const submitButton = $('.js-quick-submit input[type=submit]');
+ this.textarea.trigger(keydownEvent());
+
+ expect(submitButton).toBeDisabled();
+ });
+ it('disables button of type submit', () => {
+ const submitButton = $('.js-quick-submit input[type=submit]');
+ this.textarea.trigger(keydownEvent());
+
+ expect(submitButton).toBeDisabled();
+ });
+ it('only clicks one submit', () => {
+ 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');
+ 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());
return expect(this.spies.submit).toHaveBeenTriggered();
});
- it('excludes other modifier keys', function() {
+
+ it('excludes other modifier keys', () => {
this.textarea.trigger(keydownEvent({
- altKey: true
+ altKey: true,
}));
this.textarea.trigger(keydownEvent({
- metaKey: true
+ ctrlKey: true,
}));
this.textarea.trigger(keydownEvent({
- shiftKey: true
+ shiftKey: true,
}));
return expect(this.spies.submit).not.toHaveBeenTriggered();
});
- }
- return keydownEvent = function(options) {
- var defaults;
- if (navigator.userAgent.match(/Macintosh/)) {
- defaults = {
- keyCode: 13,
- metaKey: true
- };
- } else {
- defaults = {
- keyCode: 13,
- ctrlKey: true
- };
- }
- return $.Event('keydown', $.extend({}, defaults, options));
- };
- });
-}).call(window);
+ });
+ } else {
+ it('responds to Ctrl+Enter', () => {
+ this.textarea.trigger(keydownEvent());
+ return expect(this.spies.submit).toHaveBeenTriggered();
+ });
+
+ it('excludes other modifier keys', () => {
+ this.textarea.trigger(keydownEvent({
+ altKey: true,
+ }));
+ this.textarea.trigger(keydownEvent({
+ metaKey: true,
+ }));
+ this.textarea.trigger(keydownEvent({
+ shiftKey: true,
+ }));
+ return expect(this.spies.submit).not.toHaveBeenTriggered();
+ });
+ }
+});
diff --git a/spec/javascripts/fixtures/merge_requests.rb b/spec/javascripts/fixtures/merge_requests.rb
index f97a5d2b5de..41700458aae 100644
--- a/spec/javascripts/fixtures/merge_requests.rb
+++ b/spec/javascripts/fixtures/merge_requests.rb
@@ -55,6 +55,11 @@ describe Projects::MergeRequestsController, '(JavaScript fixtures)', type: :cont
render_merge_request(example.description, merge_request)
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
def render_merge_request(fixture_file_name, merge_request)
diff --git a/spec/javascripts/issue_show/components/app_spec.js b/spec/javascripts/issue_show/components/app_spec.js
index 81ce18bf2fb..3af26e2f28f 100644
--- a/spec/javascripts/issue_show/components/app_spec.js
+++ b/spec/javascripts/issue_show/components/app_spec.js
@@ -41,9 +41,9 @@ describe('Issuable output', () => {
initialTitleText: '',
initialDescriptionHtml: '',
initialDescriptionText: '',
- markdownPreviewUrl: '/',
- markdownDocs: '/',
- projectsAutocompleteUrl: '/',
+ markdownPreviewPath: '/',
+ markdownDocsPath: '/',
+ projectsAutocompletePath: '/',
isConfidential: false,
projectNamespace: '/',
projectPath: '/',
diff --git a/spec/javascripts/issue_show/components/fields/description_spec.js b/spec/javascripts/issue_show/components/fields/description_spec.js
index df8189d9290..299f88e7778 100644
--- a/spec/javascripts/issue_show/components/fields/description_spec.js
+++ b/spec/javascripts/issue_show/components/fields/description_spec.js
@@ -25,8 +25,8 @@ describe('Description field component', () => {
vm = new Component({
el,
propsData: {
- markdownPreviewUrl: '/',
- markdownDocs: '/',
+ markdownPreviewPath: '/',
+ markdownDocsPath: '/',
formState: store.formState,
},
}).$mount();
diff --git a/spec/javascripts/issue_show/components/fields/project_move_spec.js b/spec/javascripts/issue_show/components/fields/project_move_spec.js
index 86d35c33ff4..8b6ed6a03a9 100644
--- a/spec/javascripts/issue_show/components/fields/project_move_spec.js
+++ b/spec/javascripts/issue_show/components/fields/project_move_spec.js
@@ -15,7 +15,7 @@ describe('Project move field component', () => {
vm = new Component({
propsData: {
formState,
- projectsAutocompleteUrl: '/autocomplete',
+ projectsAutocompletePath: '/autocomplete',
},
}).$mount();
diff --git a/spec/javascripts/issue_show/components/form_spec.js b/spec/javascripts/issue_show/components/form_spec.js
index 9a85223208c..d8af5287431 100644
--- a/spec/javascripts/issue_show/components/form_spec.js
+++ b/spec/javascripts/issue_show/components/form_spec.js
@@ -18,9 +18,9 @@ describe('Inline edit form component', () => {
description: 'a',
lockedWarningVisible: false,
},
- markdownPreviewUrl: '/',
- markdownDocs: '/',
- projectsAutocompleteUrl: '/',
+ markdownPreviewPath: '/',
+ markdownDocsPath: '/',
+ projectsAutocompletePath: '/',
projectPath: '/',
projectNamespace: '/',
},
diff --git a/spec/javascripts/notes/components/issue_comment_form_spec.js b/spec/javascripts/notes/components/issue_comment_form_spec.js
new file mode 100644
index 00000000000..cca5ec887a3
--- /dev/null
+++ b/spec/javascripts/notes/components/issue_comment_form_spec.js
@@ -0,0 +1,134 @@
+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);
+ });
+ });
+});
diff --git a/spec/javascripts/notes/components/issue_discussion_spec.js b/spec/javascripts/notes/components/issue_discussion_spec.js
new file mode 100644
index 00000000000..05c6b57f93e
--- /dev/null
+++ b/spec/javascripts/notes/components/issue_discussion_spec.js
@@ -0,0 +1,50 @@
+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();
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/notes/components/issue_note_actions_spec.js b/spec/javascripts/notes/components/issue_note_actions_spec.js
new file mode 100644
index 00000000000..7bcc061f167
--- /dev/null
+++ b/spec/javascripts/notes/components/issue_note_actions_spec.js
@@ -0,0 +1,91 @@
+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);
+ });
+ });
+});
diff --git a/spec/javascripts/notes/components/issue_note_app_spec.js b/spec/javascripts/notes/components/issue_note_app_spec.js
new file mode 100644
index 00000000000..22e91c4c40f
--- /dev/null
+++ b/spec/javascripts/notes/components/issue_note_app_spec.js
@@ -0,0 +1,255 @@
+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);
+ });
+ });
+});
diff --git a/spec/javascripts/notes/components/issue_note_attachment_spec.js b/spec/javascripts/notes/components/issue_note_attachment_spec.js
new file mode 100644
index 00000000000..8f33b874ad6
--- /dev/null
+++ b/spec/javascripts/notes/components/issue_note_attachment_spec.js
@@ -0,0 +1,23 @@
+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);
+ });
+});
diff --git a/spec/javascripts/notes/components/issue_note_awards_list_spec.js b/spec/javascripts/notes/components/issue_note_awards_list_spec.js
new file mode 100644
index 00000000000..3b6c34f1494
--- /dev/null
+++ b/spec/javascripts/notes/components/issue_note_awards_list_spec.js
@@ -0,0 +1,56 @@
+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();
+ });
+});
diff --git a/spec/javascripts/notes/components/issue_note_body_spec.js b/spec/javascripts/notes/components/issue_note_body_spec.js
new file mode 100644
index 00000000000..81f07ed47cc
--- /dev/null
+++ b/spec/javascripts/notes/components/issue_note_body_spec.js
@@ -0,0 +1,46 @@
+
+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();
+ });
+});
diff --git a/spec/javascripts/notes/components/issue_note_edited_text_spec.js b/spec/javascripts/notes/components/issue_note_edited_text_spec.js
new file mode 100644
index 00000000000..6603241eb64
--- /dev/null
+++ b/spec/javascripts/notes/components/issue_note_edited_text_spec.js
@@ -0,0 +1,47 @@
+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);
+ });
+});
diff --git a/spec/javascripts/notes/components/issue_note_form_spec.js b/spec/javascripts/notes/components/issue_note_form_spec.js
new file mode 100644
index 00000000000..a90dbcb72b5
--- /dev/null
+++ b/spec/javascripts/notes/components/issue_note_form_spec.js
@@ -0,0 +1,112 @@
+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();
+ });
+ });
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/notes/components/issue_note_header_spec.js b/spec/javascripts/notes/components/issue_note_header_spec.js
new file mode 100644
index 00000000000..83ea18508ae
--- /dev/null
+++ b/spec/javascripts/notes/components/issue_note_header_spec.js
@@ -0,0 +1,94 @@
+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();
+ });
+ });
+ });
+});
diff --git a/spec/javascripts/notes/components/issue_note_signed_out_widget_spec.js b/spec/javascripts/notes/components/issue_note_signed_out_widget_spec.js
new file mode 100644
index 00000000000..f20d9ce9268
--- /dev/null
+++ b/spec/javascripts/notes/components/issue_note_signed_out_widget_spec.js
@@ -0,0 +1,37 @@
+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');
+ });
+});
diff --git a/spec/javascripts/notes/components/issue_note_spec.js b/spec/javascripts/notes/components/issue_note_spec.js
new file mode 100644
index 00000000000..7ef85d5b4f0
--- /dev/null
+++ b/spec/javascripts/notes/components/issue_note_spec.js
@@ -0,0 +1,44 @@
+
+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);
+ });
+});
diff --git a/spec/javascripts/notes/components/issue_placeholder_note_spec.js b/spec/javascripts/notes/components/issue_placeholder_note_spec.js
new file mode 100644
index 00000000000..6e5275087f3
--- /dev/null
+++ b/spec/javascripts/notes/components/issue_placeholder_note_spec.js
@@ -0,0 +1,39 @@
+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');
+ });
+ });
+});
diff --git a/spec/javascripts/notes/components/issue_placeholder_system_note_spec.js b/spec/javascripts/notes/components/issue_placeholder_system_note_spec.js
new file mode 100644
index 00000000000..d508a49f710
--- /dev/null
+++ b/spec/javascripts/notes/components/issue_placeholder_system_note_spec.js
@@ -0,0 +1,24 @@
+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');
+ });
+});
diff --git a/spec/javascripts/notes/components/issue_system_note_spec.js b/spec/javascripts/notes/components/issue_system_note_spec.js
new file mode 100644
index 00000000000..c317ce32716
--- /dev/null
+++ b/spec/javascripts/notes/components/issue_system_note_spec.js
@@ -0,0 +1,53 @@
+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);
+ });
+});
diff --git a/spec/javascripts/notes/mock_data.js b/spec/javascripts/notes/mock_data.js
new file mode 100644
index 00000000000..89ba3a002b7
--- /dev/null
+++ b/spec/javascripts/notes/mock_data.js
@@ -0,0 +1,449 @@
+/* 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
+}];
diff --git a/spec/javascripts/notes/stores/actions_spec.js b/spec/javascripts/notes/stores/actions_spec.js
new file mode 100644
index 00000000000..72d362acb2f
--- /dev/null
+++ b/spec/javascripts/notes/stores/actions_spec.js
@@ -0,0 +1,62 @@
+
+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);
+ });
+ });
+});
diff --git a/spec/javascripts/notes/stores/getters_spec.js b/spec/javascripts/notes/stores/getters_spec.js
new file mode 100644
index 00000000000..48ee1bf9a52
--- /dev/null
+++ b/spec/javascripts/notes/stores/getters_spec.js
@@ -0,0 +1,58 @@
+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]);
+ });
+ });
+});
diff --git a/spec/javascripts/notes/stores/helpers.js b/spec/javascripts/notes/stores/helpers.js
new file mode 100644
index 00000000000..2d386fe1da5
--- /dev/null
+++ b/spec/javascripts/notes/stores/helpers.js
@@ -0,0 +1,37 @@
+/* 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();
+ }
+};
diff --git a/spec/javascripts/notes/stores/mutation_spec.js b/spec/javascripts/notes/stores/mutation_spec.js
new file mode 100644
index 00000000000..a38f29c1e39
--- /dev/null
+++ b/spec/javascripts/notes/stores/mutation_spec.js
@@ -0,0 +1,207 @@
+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');
+ });
+ });
+});
diff --git a/spec/javascripts/notes_spec.js b/spec/javascripts/notes_spec.js
index 2c096ed08a8..8c5ad8914b0 100644
--- a/spec/javascripts/notes_spec.js
+++ b/spec/javascripts/notes_spec.js
@@ -32,14 +32,14 @@ import '~/notes';
describe('Notes', function() {
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);
beforeEach(function () {
loadFixtures(commentsTemplate);
gl.utils.disableButtonIfEmptyField = _.noop;
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() {
@@ -53,17 +53,19 @@ import '~/notes';
it('modifies the Markdown field', function() {
const changeEvent = document.createEvent('HTMLEvents');
changeEvent.initEvent('change', true, true);
- $('input[type=checkbox]').attr('checked', true)[0].dispatchEvent(changeEvent);
- expect($('.js-task-list-field').val()).toBe('- [x] Task List Item');
+ $('input[type=checkbox]').attr('checked', true)[1].dispatchEvent(changeEvent);
+
+ expect($('.js-task-list-field.original-task-list').val()).toBe('- [x] Task List Item');
});
it('submits an ajax request on tasklist:changed', function() {
spyOn(jQuery, 'ajax').and.callFake(function(req) {
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);
});
- $('.js-task-list-field').trigger('tasklist:changed');
+
+ $('.js-task-list-field.js-note-text').trigger('tasklist:changed');
});
});
diff --git a/spec/javascripts/shortcuts_issuable_spec.js b/spec/javascripts/shortcuts_issuable_spec.js
index 3515dfbc60b..a912e150e9b 100644
--- a/spec/javascripts/shortcuts_issuable_spec.js
+++ b/spec/javascripts/shortcuts_issuable_spec.js
@@ -1,78 +1,74 @@
-/* eslint-disable space-before-function-paren, no-return-assign, no-var, quotes */
/* global ShortcutsIssuable */
import '~/copy_as_gfm';
import '~/shortcuts_issuable';
-(function() {
- describe('ShortcutsIssuable', function() {
- var fixtureName = 'issues/open-issue.html.raw';
- preloadFixtures(fixtureName);
- beforeEach(function() {
- loadFixtures(fixtureName);
- document.querySelector('.js-new-note-form').classList.add('js-main-target-form');
- this.shortcut = new ShortcutsIssuable();
- });
- describe('replyWithSelectedText', function() {
- var stubSelection;
- // Stub window.gl.utils.getSelectedFragment to return a node with the provided HTML.
- stubSelection = function(html) {
- window.gl.utils.getSelectedFragment = function() {
- var node = document.createElement('div');
- node.innerHTML = html;
- return node;
- };
+describe('ShortcutsIssuable', () => {
+ const fixtureName = 'merge_requests/diff_comment.html.raw';
+ preloadFixtures(fixtureName);
+ beforeEach(() => {
+ loadFixtures(fixtureName);
+ document.querySelector('.js-new-note-form').classList.add('js-main-target-form');
+ this.shortcut = new ShortcutsIssuable(true);
+ });
+ describe('replyWithSelectedText', () => {
+ // Stub window.gl.utils.getSelectedFragment to return a node with the provided HTML.
+ const stubSelection = (html) => {
+ window.gl.utils.getSelectedFragment = () => {
+ const node = document.createElement('div');
+ 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('does not return an error', function() {
- this.shortcut.replyWithSelectedText();
- expect($(this.selector).val()).toBe('');
- });
- it('triggers `focus`', function() {
- this.shortcut.replyWithSelectedText();
- expect(document.activeElement).toBe(document.querySelector(this.selector));
- });
+ it('triggers `focus`', () => {
+ this.shortcut.replyWithSelectedText(true);
+ expect(document.activeElement).toBe(document.querySelector(this.selector));
});
- describe('with any selection', function() {
- beforeEach(function() {
- 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 any selection', () => {
+ beforeEach(() => {
+ stubSelection('<p>Selected text.</p>');
});
- describe('with a one-line selection', function() {
- it('quotes the selection', function() {
- stubSelection('<p>This text has been selected.</p>');
- this.shortcut.replyWithSelectedText();
- expect($(this.selector).val()).toBe("> This text has been selected.\n\n");
- });
+ it('leaves existing input intact', () => {
+ $(this.selector).val('This text was already here.');
+ expect($(this.selector).val()).toBe('This text was already here.');
+ this.shortcut.replyWithSelectedText(true);
+ expect($(this.selector).val()).toBe('This text was already here.\n\n> Selected text.\n\n');
});
- describe('with a multi-line selection', function() {
- it('quotes the selected lines as a group', function() {
- stubSelection("<p>Selected line one.</p>\n\n<p>Selected line two.</p>\n\n<p>Selected line three.</p>");
- this.shortcut.replyWithSelectedText();
- expect($(this.selector).val()).toBe("> Selected line one.\n>\n> Selected line two.\n>\n> Selected line three.\n\n");
+ it('triggers `input`', () => {
+ let triggered = false;
+ $(this.selector).on('input', () => {
+ triggered = true;
});
+ 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);
+});
diff --git a/spec/javascripts/shortcuts_spec.js b/spec/javascripts/shortcuts_spec.js
index 9b8373df29e..53e4c68beb3 100644
--- a/spec/javascripts/shortcuts_spec.js
+++ b/spec/javascripts/shortcuts_spec.js
@@ -1,6 +1,6 @@
/* global 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, {
target,
});
diff --git a/spec/javascripts/vue_shared/components/issue/confidential_issue_warning_spec.js b/spec/javascripts/vue_shared/components/issue/confidential_issue_warning_spec.js
new file mode 100644
index 00000000000..6df08f3ebe7
--- /dev/null
+++ b/spec/javascripts/vue_shared/components/issue/confidential_issue_warning_spec.js
@@ -0,0 +1,20 @@
+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.');
+ });
+});
diff --git a/spec/javascripts/vue_shared/components/markdown/field_spec.js b/spec/javascripts/vue_shared/components/markdown/field_spec.js
index 291e19c9f3c..60a5c2ae74e 100644
--- a/spec/javascripts/vue_shared/components/markdown/field_spec.js
+++ b/spec/javascripts/vue_shared/components/markdown/field_spec.js
@@ -16,8 +16,8 @@ describe('Markdown field component', () => {
},
template: `
<field-component
- marodown-preview-url="/preview"
- markdown-docs="/docs"
+ markdown-preview-path="/preview"
+ markdown-docs-path="/docs"
>
<textarea
slot="textarea"
@@ -92,6 +92,7 @@ describe('Markdown field component', () => {
it('renders GFM with jQuery', (done) => {
spyOn($.fn, 'renderGFM');
+
previewLink.click();
setTimeout(() => {
@@ -100,7 +101,7 @@ describe('Markdown field component', () => {
).toHaveBeenCalled();
done();
- });
+ }, 0);
});
});
diff --git a/spec/javascripts/zen_mode_spec.js b/spec/javascripts/zen_mode_spec.js
index a225b04c47e..bd18f79cea7 100644
--- a/spec/javascripts/zen_mode_spec.js
+++ b/spec/javascripts/zen_mode_spec.js
@@ -8,7 +8,7 @@ import ZenMode from '~/zen_mode';
var enterZen, escapeKeydown, exitZen;
describe('ZenMode', function() {
- var fixtureName = 'issues/open-issue.html.raw';
+ var fixtureName = 'merge_requests/merge_request_with_comment.html.raw';
preloadFixtures(fixtureName);
beforeEach(function() {
loadFixtures(fixtureName);
diff --git a/spec/models/award_emoji_spec.rb b/spec/models/award_emoji_spec.rb
index 87e60d9c16b..b909e04dfc3 100644
--- a/spec/models/award_emoji_spec.rb
+++ b/spec/models/award_emoji_spec.rb
@@ -41,4 +41,40 @@ describe AwardEmoji do
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
diff --git a/spec/serializers/note_entity_spec.rb b/spec/serializers/note_entity_spec.rb
new file mode 100644
index 00000000000..3459cc72063
--- /dev/null
+++ b/spec/serializers/note_entity_spec.rb
@@ -0,0 +1,51 @@
+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
diff --git a/spec/support/features/discussion_comments_shared_example.rb b/spec/support/features/discussion_comments_shared_example.rb
index bb4542b1683..81cb94ab8c4 100644
--- a/spec/support/features/discussion_comments_shared_example.rb
+++ b/spec/support/features/discussion_comments_shared_example.rb
@@ -14,6 +14,8 @@ shared_examples 'discussion comments' do |resource_name|
find(submit_selector).click
+ wait_for_requests
+
find(comments_selector, match: :first)
new_comment = all(comments_selector).last
@@ -26,6 +28,7 @@ shared_examples 'discussion comments' do |resource_name|
find("#{form_selector} .note-textarea").send_keys('a')
find(close_selector).click
+ wait_for_requests
find(comments_selector, match: :first)
find("#{comments_selector}.system-note")
@@ -76,12 +79,22 @@ shared_examples 'discussion comments' do |resource_name|
it 'clicking the ul padding or divider should not change the text' do
find(menu_selector).trigger 'click'
- expect(page).to have_selector menu_selector
- expect(find(dropdown_selector)).to have_content 'Comment'
+ if resource_name == 'issue'
+ 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'
end
@@ -91,9 +104,8 @@ shared_examples 'discussion comments' do |resource_name|
all("#{menu_selector} li").last.click
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("#{form_selector} #note_type", visible: false).value).to eq('DiscussionNote')
expect(page).not_to have_selector menu_selector
end
@@ -157,9 +169,8 @@ shared_examples 'discussion comments' do |resource_name|
find("#{menu_selector} li", match: :first).click
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("#{form_selector} #note_type", visible: false).value).to eq('')
expect(page).not_to have_selector menu_selector
end
diff --git a/spec/support/features/issuable_slash_commands_shared_examples.rb b/spec/support/features/issuable_slash_commands_shared_examples.rb
index 68f0ce8afb3..8282ba7e536 100644
--- a/spec/support/features/issuable_slash_commands_shared_examples.rb
+++ b/spec/support/features/issuable_slash_commands_shared_examples.rb
@@ -21,7 +21,7 @@ shared_examples 'issuable record that supports quick actions in its description
before do
project.team << [master, :master]
- sign_in(master)
+ gitlab_sign_in(master)
end
after do
@@ -119,16 +119,15 @@ shared_examples 'issuable record that supports quick actions in its description
guest = create(:user)
project.add_guest(guest)
- sign_out(:user)
- sign_in(guest)
-
+ gitlab_sign_out
+ gitlab_sign_in(guest)
visit public_send("namespace_project_#{issuable_type}_path", project.namespace, project, issuable)
end
it "does not close the #{issuable_type}" do
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(issuable).to be_open
@@ -158,16 +157,15 @@ shared_examples 'issuable record that supports quick actions in its description
guest = create(:user)
project.add_guest(guest)
- sign_out(:user)
- sign_in(guest)
-
+ gitlab_sign_out
+ gitlab_sign_in(guest)
visit public_send("namespace_project_#{issuable_type}_path", project.namespace, project, issuable)
end
it "does not reopen the #{issuable_type}" do
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(issuable).to be_closed
@@ -192,15 +190,15 @@ shared_examples 'issuable record that supports quick actions in its description
guest = create(:user)
project.add_guest(guest)
- sign_out(:user)
- sign_in(guest)
+ gitlab_sign_out
+ gitlab_sign_in(guest)
visit public_send("namespace_project_#{issuable_type}_path", project.namespace, project, issuable)
end
it "does not reopen the #{issuable_type}" do
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(issuable.reload.title).not_to eq 'Awesome new title'
@@ -292,7 +290,7 @@ shared_examples 'issuable record that supports quick actions in its description
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
create(:user, username: 'bob')
diff --git a/spec/support/features/reportable_note_shared_examples.rb b/spec/support/features/reportable_note_shared_examples.rb
index 5a0e7c3d099..192a2fed0a8 100644
--- a/spec/support/features/reportable_note_shared_examples.rb
+++ b/spec/support/features/reportable_note_shared_examples.rb
@@ -1,6 +1,6 @@
require 'spec_helper'
-shared_examples 'reportable note' do
+shared_examples 'reportable note' do |type|
include NotesHelper
let(:comment) { find("##{ActionView::RecordIdentifier.dom_id(note)}") }
@@ -20,7 +20,12 @@ shared_examples 'reportable note' do
open_dropdown(dropdown)
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
it 'Report button links to a report page' do
diff --git a/yarn.lock b/yarn.lock
index 5245666fa43..de4a9ac4487 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -6307,6 +6307,10 @@ vue@^2.2.6:
version "2.2.6"
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:
version "1.4.0"
resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-1.4.0.tgz#4a1472bcbb952bd0a9bb4036801f954dfb39faac"