diff --git a/CHANGELOG.md b/CHANGELOG.md index ac0d22ced46..a02b6594fad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ documentation](doc/development/changelog.md) for instructions on adding your own entry. +## 9.5.3 (2017-09-03) + +- [SECURITY] Filter additional secrets from Rails logs. +- [FIXED] Make username update fail if the namespace update fails. !13642 +- [FIXED] Fix failure when issue is authored by a deleted user. !13807 +- [FIXED] Reverts changes made to signin_enabled. !13956 +- [FIXED] Fix Merge when pipeline succeeds button dropdown caret icon horizontal alignment. +- [FIXED] Fixed diff changes bar buttons from showing/hiding whilst scrolling. +- [FIXED] Fix events error importing GitLab projects. +- [FIXED] Fix pipeline trigger via API fails with 500 Internal Server Error in 9.5. +- [FIXED] Fixed fly-out nav flashing in & out. +- [FIXED] Remove closing external issues by reference error. +- [FIXED] Re-allow appearances.description_html to be NULL. +- [CHANGED] Update and fix resolvable note icons for easier recognition. +- [OTHER] Eager load head pipeline projects for MRs index. +- [OTHER] Instrument MergeRequest#fetch_ref. +- [OTHER] Instrument MergeRequest#ensure_ref_fetched. + ## 9.5.2 (2017-08-28) - [FIXED] Fix signing in using LDAP when attribute mapping uses simple strings instead of arrays. diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index 85e60ed180c..93d4c1ef06f 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -0.34.0 +0.36.0 diff --git a/Gemfile b/Gemfile index a05747e9ef5..61c941ae449 100644 --- a/Gemfile +++ b/Gemfile @@ -349,6 +349,8 @@ group :development, :test do gem 'activerecord_sane_schema_dumper', '0.2' gem 'stackprof', '~> 0.2.10', require: false + + gem 'simple_po_parser', '~> 1.1.2', require: false end group :test do diff --git a/Gemfile.lock b/Gemfile.lock index 8634a9e8822..cba30e856ed 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -723,7 +723,7 @@ GEM retriable (1.4.1) rinku (2.0.0) rotp (2.1.2) - rouge (2.2.0) + rouge (2.2.1) rqrcode (0.7.0) chunky_png rqrcode-rails3 (0.1.7) @@ -833,6 +833,7 @@ GEM faraday (~> 0.9) jwt (~> 1.5) multi_json (~> 1.10) + simple_po_parser (1.1.2) simplecov (0.14.1) docile (~> 1.1.0) json (>= 1.8, < 3) @@ -1145,6 +1146,7 @@ DEPENDENCIES sidekiq (~> 5.0) sidekiq-cron (~> 0.6.0) sidekiq-limit_fetch (~> 3.4) + simple_po_parser (~> 1.1.2) simplecov (~> 0.14.0) slack-notifier (~> 1.5.1) spinach-rails (~> 0.2.1) 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 c57472b9747..88b4267359a 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), { @@ -175,7 +175,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/fly_out_nav.js b/app/assets/javascripts/fly_out_nav.js index 81697af189b..063155a167a 100644 --- a/app/assets/javascripts/fly_out_nav.js +++ b/app/assets/javascripts/fly_out_nav.js @@ -12,6 +12,7 @@ let sidebar; export const mousePos = []; export const setSidebar = (el) => { sidebar = el; }; +export const getOpenMenu = () => currentOpenMenu; export const setOpenMenu = (menu = null) => { currentOpenMenu = menu; }; export const slope = (a, b) => (b.y - a.y) / (b.x - a.x); @@ -141,6 +142,14 @@ export const documentMouseMove = (e) => { if (mousePos.length > 6) mousePos.shift(); }; +export const subItemsMouseLeave = (relatedTarget) => { + clearTimeout(timeoutId); + + if (!relatedTarget.closest(`.${IS_OVER_CLASS}`)) { + hideMenu(currentOpenMenu); + } +}; + export default () => { sidebar = document.querySelector('.nav-sidebar'); @@ -162,10 +171,7 @@ export default () => { const subItems = el.querySelector('.sidebar-sub-level-items'); if (subItems) { - subItems.addEventListener('mouseleave', () => { - clearTimeout(timeoutId); - hideMenu(currentOpenMenu); - }); + subItems.addEventListener('mouseleave', e => subItemsMouseLeave(e.relatedTarget)); } el.addEventListener('mouseenter', e => mouseEnterTopItems(e.currentTarget)); 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/pipelines/components/graph/dropdown_job_component.vue b/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue index 7695b04db74..3e5d6d15909 100644 --- a/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue @@ -72,7 +72,7 @@ };