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" />
+ :markdown-preview-path="markdownPreviewPath" + :markdown-docs-path="markdownDocsPath"> + +
+
+ + + + +
+ + +
+ +
+ + + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + 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,36 +37,49 @@ 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.$nextTick(() => { - $(this.$refs['markdown-preview']).renderGFM(); - }); - }) - .catch(() => new Flash('Error loading markdown preview')); + 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.'; + + 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 @@ 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 @@ @@ -12,22 +16,77 @@ 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()); - - expect(submitButton).toBeDisabled(); - }); - it('only clicks one submit', function() { - const existingSubmit = $('.js-quick-submit input[type=submit]'); - // Add an extra submit button - const newSubmit = $(''); - 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/)) { - 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() { - 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({ - metaKey: true - })); - this.textarea.trigger(keydownEvent({ - 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)); + this.spies = { + submit: spyOnEvent('form', 'submit'), }; + + this.textarea = $('.js-quick-submit textarea').first(); }); -}).call(window); + + it('does not respond to other keyCodes', () => { + this.textarea.trigger(keydownEvent({ + keyCode: 32, + })); + expect(this.spies.submit).not.toHaveBeenTriggered(); + }); + + it('does not respond to Enter alone', () => { + this.textarea.trigger(keydownEvent({ + ctrlKey: false, + metaKey: false, + })); + expect(this.spies.submit).not.toHaveBeenTriggered(); + }); + + 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 = $(''); + 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', () => { + 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', () => { + 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: '

closed

', + 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: '

sdfdsaf

', + 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": "

Vel id placeat reprehenderit sit numquam.

", + "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: '

THIS IS A DICUSSSION!

', + 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: '

sadfasdsdgdsf

', + 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: '

adsfasf Should disappear

', + 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('

Selected text.

'); - }); - 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('

Selected text.

'); }); - describe('with a one-line selection', function() { - it('quotes the selection', function() { - stubSelection('

This text has been selected.

'); - 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("

Selected line one.

\n\n

Selected line two.

\n\n

Selected line three.

"); - 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('

This text has been selected.

'); + 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('

Selected line one.

\n\n

Selected line two.

\n\n

Selected line three.

'); + 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: `