diff options
author | Zeger-Jan van de Weg <git@zjvandeweg.nl> | 2017-09-04 09:28:46 +0200 |
---|---|---|
committer | Zeger-Jan van de Weg <git@zjvandeweg.nl> | 2017-09-04 09:28:46 +0200 |
commit | a315e6025c702985b2f6390b29508de39383f52d (patch) | |
tree | f0d07d955092e4a218346c41f2942131dfcef91a /app | |
parent | 78dad4cf321eb84aa5decdea34704145adca0c3e (diff) | |
parent | fd54a4678f23c9e18ce46b3803e5e57ffa1199a3 (diff) | |
download | gitlab-ce-a315e6025c702985b2f6390b29508de39383f52d.tar.gz |
Merge branch 'master' into zj-auto-devops-table
Diffstat (limited to 'app')
121 files changed, 3501 insertions, 465 deletions
diff --git a/app/assets/javascripts/autosave.js b/app/assets/javascripts/autosave.js index cfab6c40b34..4d2d4db7c0e 100644 --- a/app/assets/javascripts/autosave.js +++ b/app/assets/javascripts/autosave.js @@ -2,17 +2,17 @@ import AccessorUtilities from './lib/utils/accessor'; window.Autosave = (function() { - function Autosave(field, key) { + function Autosave(field, key, resource) { this.field = field; this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe(); - + this.resource = resource; if (key.join != null) { - key = key.join("/"); + key = key.join('/'); } - this.key = "autosave/" + key; - this.field.data("autosave", this); + this.key = 'autosave/' + key; + this.field.data('autosave', this); this.restore(); - this.field.on("input", (function(_this) { + this.field.on('input', (function(_this) { return function() { return _this.save(); }; @@ -29,7 +29,17 @@ window.Autosave = (function() { if ((text != null ? text.length : void 0) > 0) { this.field.val(text); } - return this.field.trigger("input"); + if (!this.resource && this.resource !== 'issue') { + this.field.trigger('input'); + } else { + // v-model does not update with jQuery trigger + // https://github.com/vuejs/vue/issues/2804#issuecomment-216968137 + const event = new Event('change', { bubbles: true, cancelable: false }); + const field = this.field.get(0); + if (field) { + field.dispatchEvent(event); + } + } }; Autosave.prototype.save = function() { diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js index 097f79a250a..22fa1f2a609 100644 --- a/app/assets/javascripts/awards_handler.js +++ b/app/assets/javascripts/awards_handler.js @@ -109,6 +109,7 @@ class AwardsHandler { } $thumbsBtn.toggleClass('disabled', $userAuthored); + $thumbsBtn.prop('disabled', $userAuthored); } // Create the emoji menu with the first category of emojis. @@ -234,14 +235,33 @@ class AwardsHandler { } addAward(votesBlock, awardUrl, emoji, checkMutuality, callback) { + const isMainAwardsBlock = votesBlock.closest('.js-issue-note-awards').length; + + if (gl.utils.isInIssuePage() && !isMainAwardsBlock) { + const id = votesBlock.attr('id').replace('note_', ''); + + $('.emoji-menu').removeClass('is-visible'); + $('.js-add-award.is-active').removeClass('is-active'); + const toggleAwardEvent = new CustomEvent('toggleAward', { + detail: { + awardName: emoji, + noteId: id, + }, + }); + + document.querySelector('.js-vue-notes-event').dispatchEvent(toggleAwardEvent); + } + const normalizedEmoji = this.emoji.normalizeEmojiName(emoji); const $emojiButton = this.findEmojiIcon(votesBlock, normalizedEmoji).parent(); + this.postEmoji($emojiButton, awardUrl, normalizedEmoji, () => { this.addAwardToEmojiBar(votesBlock, normalizedEmoji, checkMutuality); return typeof callback === 'function' ? callback() : undefined; }); + $('.emoji-menu').removeClass('is-visible'); - $('.js-add-award.is-active').removeClass('is-active'); + return $('.js-add-award.is-active').removeClass('is-active'); } addAwardToEmojiBar(votesBlock, emoji, checkForMutuality) { @@ -268,6 +288,14 @@ class AwardsHandler { } getVotesBlock() { + if (gl.utils.isInIssuePage()) { + const $el = $('.js-add-award.is-active').closest('.note.timeline-entry'); + + if ($el.length) { + return $el; + } + } + const currentBlock = $('.js-awards-block.current'); let resultantVotesBlock = currentBlock; if (currentBlock.length === 0) { diff --git a/app/assets/javascripts/behaviors/quick_submit.js b/app/assets/javascripts/behaviors/quick_submit.js index bc693616460..79702c54852 100644 --- a/app/assets/javascripts/behaviors/quick_submit.js +++ b/app/assets/javascripts/behaviors/quick_submit.js @@ -44,7 +44,10 @@ $(document).on('keydown.quick_submit', '.js-quick-submit', (e) => { if (!$submitButton.attr('disabled')) { $submitButton.trigger('click', [e]); - $submitButton.disable(); + + if (!gl.utils.isInIssuePage()) { + $submitButton.disable(); + } } }); diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index c70a17104fd..3dec4de06ec 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -99,7 +99,7 @@ import initChangesDropdown from './init_changes_dropdown'; path = page.split(':'); shortcut_handler = null; - $('.js-gfm-input').each((i, el) => { + $('.js-gfm-input:not(.js-vue-textarea)').each((i, el) => { const gfm = new GfmAutoComplete(gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources); const enableGFM = gl.utils.convertPermissionToBoolean(el.dataset.supportsAutocomplete); gfm.setup($(el), { @@ -172,7 +172,6 @@ import initChangesDropdown from './init_changes_dropdown'; shortcut_handler = new ShortcutsIssuable(); new ZenMode(); initIssuableSidebar(); - initNotes(); break; case 'dashboard:milestones:index': new ProjectSelect(); diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js index 6d19a6d9b3a..975903159be 100644 --- a/app/assets/javascripts/dropzone_input.js +++ b/app/assets/javascripts/dropzone_input.js @@ -128,7 +128,7 @@ window.DropzoneInput = (function() { // removeAllFiles(true) stops uploading files (if any) // and remove them from dropzone files queue. $cancelButton.on('click', (e) => { - const target = e.target.closest('form').querySelector('.div-dropzone'); + const target = e.target.closest('.js-main-target-form').querySelector('.div-dropzone'); e.preventDefault(); e.stopPropagation(); @@ -140,7 +140,7 @@ window.DropzoneInput = (function() { // and add that files to the dropzone files queue again. // addFile() adds file to dropzone files queue and upload it. $retryLink.on('click', (e) => { - const dropzoneInstance = Dropzone.forElement(e.target.closest('form').querySelector('.div-dropzone')); + const dropzoneInstance = Dropzone.forElement(e.target.closest('.js-main-target-form').querySelector('.div-dropzone')); const failedFiles = dropzoneInstance.files; e.preventDefault(); diff --git a/app/assets/javascripts/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" /> <div v-else> <title-component diff --git a/app/assets/javascripts/issue_show/components/fields/description.vue b/app/assets/javascripts/issue_show/components/fields/description.vue index 27b1b814f9a..dc902eefc5f 100644 --- a/app/assets/javascripts/issue_show/components/fields/description.vue +++ b/app/assets/javascripts/issue_show/components/fields/description.vue @@ -10,11 +10,11 @@ type: Object, required: true, }, - markdownPreviewUrl: { + markdownPreviewPath: { type: String, required: true, }, - markdownDocs: { + markdownDocsPath: { type: String, required: true, }, @@ -36,8 +36,8 @@ Description </label> <markdown-field - :markdown-preview-url="markdownPreviewUrl" - :markdown-docs="markdownDocs"> + :markdown-preview-path="markdownPreviewPath" + :markdown-docs-path="markdownDocsPath"> <textarea id="issue-description" class="note-textarea js-gfm-input js-autosize markdown-area" diff --git a/app/assets/javascripts/issue_show/components/fields/project_move.vue b/app/assets/javascripts/issue_show/components/fields/project_move.vue index 7bf2be8b28a..e514bebc5f6 100644 --- a/app/assets/javascripts/issue_show/components/fields/project_move.vue +++ b/app/assets/javascripts/issue_show/components/fields/project_move.vue @@ -10,7 +10,7 @@ type: Object, required: true, }, - projectsAutocompleteUrl: { + projectsAutocompletePath: { type: String, required: true, }, @@ -20,7 +20,7 @@ $moveDropdown.select2({ ajax: { - url: this.projectsAutocompleteUrl, + url: this.projectsAutocompletePath, quietMillis: 125, data(term, page, context) { return { diff --git a/app/assets/javascripts/issue_show/components/form.vue b/app/assets/javascripts/issue_show/components/form.vue index 76ec3dc9a5d..d9b53bc55cf 100644 --- a/app/assets/javascripts/issue_show/components/form.vue +++ b/app/assets/javascripts/issue_show/components/form.vue @@ -26,11 +26,11 @@ required: false, default: () => [], }, - markdownPreviewUrl: { + markdownPreviewPath: { type: String, required: true, }, - markdownDocs: { + markdownDocsPath: { type: String, required: true, }, @@ -42,7 +42,7 @@ type: String, required: true, }, - projectsAutocompleteUrl: { + projectsAutocompletePath: { type: String, required: true, }, @@ -89,14 +89,14 @@ </div> <description-field :form-state="formState" - :markdown-preview-url="markdownPreviewUrl" - :markdown-docs="markdownDocs" /> + :markdown-preview-path="markdownPreviewPath" + :markdown-docs-path="markdownDocsPath" /> <confidential-checkbox :form-state="formState" /> <project-move v-if="canMove" :form-state="formState" - :projects-autocomplete-url="projectsAutocompleteUrl" /> + :projects-autocomplete-path="projectsAutocompletePath" /> <edit-actions :form-state="formState" :can-destroy="canDestroy" /> diff --git a/app/assets/javascripts/issue_show/index.js b/app/assets/javascripts/issue_show/index.js index ad8cb6465e2..60b69b300fd 100644 --- a/app/assets/javascripts/issue_show/index.js +++ b/app/assets/javascripts/issue_show/index.js @@ -37,11 +37,11 @@ document.addEventListener('DOMContentLoaded', () => { initialDescriptionText: this.initialDescriptionText, issuableTemplates: this.issuableTemplates, isConfidential: this.isConfidential, - markdownPreviewUrl: this.markdownPreviewUrl, - markdownDocs: this.markdownDocs, + markdownPreviewPath: this.markdownPreviewPath, + markdownDocsPath: this.markdownDocsPath, projectPath: this.projectPath, projectNamespace: this.projectNamespace, - projectsAutocompleteUrl: this.projectsAutocompleteUrl, + projectsAutocompletePath: this.projectsAutocompletePath, updatedAt: this.updatedAt, updatedByName: this.updatedByName, updatedByPath: this.updatedByPath, diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index b8f4f4eaba3..b8bebe1894f 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -27,6 +27,13 @@ } }; + w.gl.utils.isInIssuePage = () => { + const page = gl.utils.getPagePath(1); + const action = gl.utils.getPagePath(2); + + return page === 'issues' && action === 'show'; + }; + w.gl.utils.ajaxGet = function(url) { return $.ajax({ type: "GET", @@ -167,11 +174,12 @@ }; gl.utils.scrollToElement = function($el) { - var top = $el.offset().top; - gl.mrTabsHeight = gl.mrTabsHeight || $('.merge-request-tabs').height(); + const top = $el.offset().top; + const mrTabsHeight = $('.merge-request-tabs').height() || 0; + const headerHeight = $('.navbar-gitlab').height() || 0; return $('body, html').animate({ - scrollTop: top - (gl.mrTabsHeight) + scrollTop: top - mrTabsHeight - headerHeight, }, 200); }; diff --git a/app/assets/javascripts/lib/utils/pretty_time.js b/app/assets/javascripts/lib/utils/pretty_time.js index 716aefbfcb7..227bf65b560 100644 --- a/app/assets/javascripts/lib/utils/pretty_time.js +++ b/app/assets/javascripts/lib/utils/pretty_time.js @@ -2,19 +2,20 @@ import _ from 'underscore'; (() => { /* - * TODO: Make these methods more configurable (e.g. parseSeconds timePeriodContstraints, - * stringifyTime condensed or non-condensed, abbreviateTimelengths) + * TODO: Make these methods more configurable (e.g. stringifyTime condensed or + * non-condensed, abbreviateTimelengths) * */ const utils = window.gl.utils = gl.utils || {}; const prettyTime = utils.prettyTime = { /* * Accepts seconds and returns a timeObject { weeks: #, days: #, hours: #, minutes: # } - * Seconds can be negative or positive, zero or non-zero. + * Seconds can be negative or positive, zero or non-zero. Can be configured for any day + * or week length. */ - parseSeconds(seconds) { - const DAYS_PER_WEEK = 5; - const HOURS_PER_DAY = 8; + parseSeconds(seconds, { daysPerWeek = 5, hoursPerDay = 8 } = {}) { + const DAYS_PER_WEEK = daysPerWeek; + const HOURS_PER_DAY = hoursPerDay; const MINUTES_PER_HOUR = 60; const MINUTES_PER_WEEK = DAYS_PER_WEEK * HOURS_PER_DAY * MINUTES_PER_HOUR; const MINUTES_PER_DAY = HOURS_PER_DAY * MINUTES_PER_HOUR; diff --git a/app/assets/javascripts/notes/components/issue_comment_form.vue b/app/assets/javascripts/notes/components/issue_comment_form.vue new file mode 100644 index 00000000000..16f4e22aa9b --- /dev/null +++ b/app/assets/javascripts/notes/components/issue_comment_form.vue @@ -0,0 +1,347 @@ +<script> + /* global Flash, Autosave */ + import { mapActions, mapGetters } from 'vuex'; + import _ from 'underscore'; + import '../../autosave'; + import TaskList from '../../task_list'; + import * as constants from '../constants'; + import eventHub from '../event_hub'; + import confidentialIssue from '../../vue_shared/components/issue/confidential_issue_warning.vue'; + import issueNoteSignedOutWidget from './issue_note_signed_out_widget.vue'; + import markdownField from '../../vue_shared/components/markdown/field.vue'; + import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; + + export default { + name: 'issueCommentForm', + data() { + return { + note: '', + noteType: constants.COMMENT, + // Can't use mapGetters, + // this needs to be in the data object because it belongs to the state + issueState: this.$store.getters.getIssueData.state, + isSubmitting: false, + isSubmitButtonDisabled: true, + }; + }, + components: { + confidentialIssue, + issueNoteSignedOutWidget, + markdownField, + userAvatarLink, + }, + watch: { + note(newNote) { + this.setIsSubmitButtonDisabled(newNote, this.isSubmitting); + }, + isSubmitting(newValue) { + this.setIsSubmitButtonDisabled(this.note, newValue); + }, + }, + computed: { + ...mapGetters([ + 'getCurrentUserLastNote', + 'getUserData', + 'getIssueData', + 'getNotesData', + ]), + isLoggedIn() { + return this.getUserData.id; + }, + commentButtonTitle() { + return this.noteType === constants.COMMENT ? 'Comment' : 'Start discussion'; + }, + isIssueOpen() { + return this.issueState === constants.OPENED || this.issueState === constants.REOPENED; + }, + issueActionButtonTitle() { + if (this.note.length) { + const actionText = this.isIssueOpen ? 'close' : 'reopen'; + + return this.noteType === constants.COMMENT ? `Comment & ${actionText} issue` : `Start discussion & ${actionText} issue`; + } + + return this.isIssueOpen ? 'Close issue' : 'Reopen issue'; + }, + actionButtonClassNames() { + return { + 'btn-reopen': !this.isIssueOpen, + 'btn-close': this.isIssueOpen, + 'js-note-target-close': this.isIssueOpen, + 'js-note-target-reopen': !this.isIssueOpen, + }; + }, + markdownDocsPath() { + return this.getNotesData.markdownDocsPath; + }, + quickActionsDocsPath() { + return this.getNotesData.quickActionsDocsPath; + }, + markdownPreviewPath() { + return this.getIssueData.preview_note_path; + }, + author() { + return this.getUserData; + }, + canUpdateIssue() { + return this.getIssueData.current_user.can_update; + }, + endpoint() { + return this.getIssueData.create_note_path; + }, + isConfidentialIssue() { + return this.getIssueData.confidential; + }, + }, + methods: { + ...mapActions([ + 'saveNote', + 'removePlaceholderNotes', + ]), + setIsSubmitButtonDisabled(note, isSubmitting) { + if (!_.isEmpty(note) && !isSubmitting) { + this.isSubmitButtonDisabled = false; + } else { + this.isSubmitButtonDisabled = true; + } + }, + handleSave(withIssueAction) { + if (this.note.length) { + const noteData = { + endpoint: this.endpoint, + flashContainer: this.$el, + data: { + note: { + noteable_type: constants.NOTEABLE_TYPE, + noteable_id: this.getIssueData.id, + note: this.note, + }, + }, + }; + + if (this.noteType === constants.DISCUSSION) { + noteData.data.note.type = constants.DISCUSSION_NOTE; + } + this.isSubmitting = true; + this.note = ''; // Empty textarea while being requested. Repopulate in catch + + this.saveNote(noteData) + .then((res) => { + this.isSubmitting = false; + if (res.errors) { + if (res.errors.commands_only) { + this.discard(); + } else { + Flash( + 'Something went wrong while adding your comment. Please try again.', + 'alert', + $(this.$refs.commentForm), + ); + } + } else { + this.discard(); + } + + if (withIssueAction) { + this.toggleIssueState(); + } + }) + .catch(() => { + this.isSubmitting = false; + this.discard(false); + const msg = 'Your comment could not be submitted! Please check your network connection and try again.'; + Flash(msg, 'alert', $(this.$el)); + this.note = noteData.data.note.note; // Restore textarea content. + this.removePlaceholderNotes(); + }); + } else { + this.toggleIssueState(); + } + }, + toggleIssueState() { + this.issueState = this.isIssueOpen ? constants.CLOSED : constants.REOPENED; + + // This is out of scope for the Notes Vue component. + // It was the shortest path to update the issue state and relevant places. + const btnClass = this.isIssueOpen ? 'btn-reopen' : 'btn-close'; + $(`.js-btn-issue-action.${btnClass}:visible`).trigger('click'); + }, + discard(shouldClear = true) { + // `blur` is needed to clear slash commands autocomplete cache if event fired. + // `focus` is needed to remain cursor in the textarea. + this.$refs.textarea.blur(); + this.$refs.textarea.focus(); + + if (shouldClear) { + this.note = ''; + } + + // reset autostave + this.autosave.reset(); + }, + setNoteType(type) { + this.noteType = type; + }, + editCurrentUserLastNote() { + if (this.note === '') { + const lastNote = this.getCurrentUserLastNote; + + if (lastNote) { + eventHub.$emit('enterEditMode', { + noteId: lastNote.id, + }); + } + } + }, + initAutoSave() { + if (this.isLoggedIn) { + this.autosave = new Autosave($(this.$refs.textarea), ['Note', 'Issue', this.getIssueData.id], 'issue'); + } + }, + initTaskList() { + return new TaskList({ + dataType: 'note', + fieldName: 'note', + selector: '.notes', + }); + }, + }, + mounted() { + // jQuery is needed here because it is a custom event being dispatched with jQuery. + $(document).on('issuable:change', (e, isClosed) => { + this.issueState = isClosed ? constants.CLOSED : constants.REOPENED; + }); + + this.initAutoSave(); + this.initTaskList(); + }, + }; +</script> + +<template> + <div> + <issue-note-signed-out-widget v-if="!isLoggedIn" /> + <ul + v-else + class="notes notes-form timeline"> + <li class="timeline-entry"> + <div class="timeline-entry-inner"> + <div class="flash-container error-alert timeline-content"></div> + <div class="timeline-icon hidden-xs hidden-sm"> + <user-avatar-link + v-if="author" + :link-href="author.path" + :img-src="author.avatar_url" + :img-alt="author.name" + :img-size="40" + /> + </div> + <div class="timeline-content timeline-content-form"> + <form + ref="commentForm" + class="new-note js-quick-submit common-note-form gfm-form js-main-target-form"> + <confidentialIssue v-if="isConfidentialIssue" /> + <div class="error-alert"></div> + <markdown-field + :markdown-preview-path="markdownPreviewPath" + :markdown-docs-path="markdownDocsPath" + :quick-actions-docs-path="quickActionsDocsPath" + :add-spacing-classes="false" + :is-confidential-issue="isConfidentialIssue"> + <textarea + id="note-body" + name="note[note]" + class="note-textarea js-vue-comment-form js-gfm-input js-autosize markdown-area js-vue-textarea" + data-supports-quick-actions="true" + aria-label="Description" + v-model="note" + ref="textarea" + slot="textarea" + placeholder="Write a comment or drag your files here..." + @keydown.up="editCurrentUserLastNote()" + @keydown.meta.enter="handleSave()"> + </textarea> + </markdown-field> + <div class="note-form-actions"> + <div class="pull-left btn-group append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown"> + <button + @click.prevent="handleSave()" + :disabled="isSubmitButtonDisabled" + class="btn btn-create comment-btn js-comment-button js-comment-submit-button" + type="submit"> + {{commentButtonTitle}} + </button> + <button + :disabled="isSubmitButtonDisabled" + name="button" + type="button" + class="btn comment-btn note-type-toggle js-note-new-discussion dropdown-toggle" + data-toggle="dropdown" + aria-label="Open comment type dropdown"> + <i + aria-hidden="true" + class="fa fa-caret-down toggle-icon"> + </i> + </button> + + <ul class="note-type-dropdown dropdown-open-top dropdown-menu"> + <li :class="{ 'droplab-item-selected': noteType === 'comment' }"> + <button + type="button" + class="btn btn-transparent" + @click.prevent="setNoteType('comment')"> + <i + aria-hidden="true" + class="fa fa-check icon"> + </i> + <div class="description"> + <strong>Comment</strong> + <p> + Add a general comment to this issue. + </p> + </div> + </button> + </li> + <li class="divider droplab-item-ignore"></li> + <li :class="{ 'droplab-item-selected': noteType === 'discussion' }"> + <button + type="button" + class="btn btn-transparent" + @click.prevent="setNoteType('discussion')"> + <i + aria-hidden="true" + class="fa fa-check icon"> + </i> + <div class="description"> + <strong>Start discussion</strong> + <p> + Discuss a specific suggestion or question. + </p> + </div> + </button> + </li> + </ul> + </div> + <button + type="button" + @click="handleSave(true)" + v-if="canUpdateIssue" + :class="actionButtonClassNames" + class="btn btn-comment btn-comment-and-close"> + {{issueActionButtonTitle}} + </button> + <button + type="button" + v-if="note.length" + @click="discard" + class="btn btn-cancel js-note-discard"> + Discard draft + </button> + </div> + </form> + </div> + </div> + </li> + </ul> + </div> +</template> diff --git a/app/assets/javascripts/notes/components/issue_discussion.vue b/app/assets/javascripts/notes/components/issue_discussion.vue new file mode 100644 index 00000000000..b131ef4b182 --- /dev/null +++ b/app/assets/javascripts/notes/components/issue_discussion.vue @@ -0,0 +1,232 @@ +<script> + /* global Flash */ + import { mapActions, mapGetters } from 'vuex'; + import { SYSTEM_NOTE } from '../constants'; + import issueNote from './issue_note.vue'; + import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; + import issueNoteHeader from './issue_note_header.vue'; + import issueNoteActions from './issue_note_actions.vue'; + import issueNoteSignedOutWidget from './issue_note_signed_out_widget.vue'; + import issueNoteEditedText from './issue_note_edited_text.vue'; + import issueNoteForm from './issue_note_form.vue'; + import placeholderNote from './issue_placeholder_note.vue'; + import placeholderSystemNote from './issue_placeholder_system_note.vue'; + import autosave from '../mixins/autosave'; + + export default { + props: { + note: { + type: Object, + required: true, + }, + }, + data() { + return { + isReplying: false, + }; + }, + components: { + issueNote, + userAvatarLink, + issueNoteHeader, + issueNoteActions, + issueNoteSignedOutWidget, + issueNoteEditedText, + issueNoteForm, + placeholderNote, + placeholderSystemNote, + }, + mixins: [ + autosave, + ], + computed: { + ...mapGetters([ + 'getIssueData', + ]), + discussion() { + return this.note.notes[0]; + }, + author() { + return this.discussion.author; + }, + canReply() { + return this.getIssueData.current_user.can_create_note; + }, + newNotePath() { + return this.getIssueData.create_note_path; + }, + lastUpdatedBy() { + const { notes } = this.note; + + if (notes.length > 1) { + return notes[notes.length - 1].author; + } + + return null; + }, + lastUpdatedAt() { + const { notes } = this.note; + + if (notes.length > 1) { + return notes[notes.length - 1].created_at; + } + + return null; + }, + }, + methods: { + ...mapActions([ + 'saveNote', + 'toggleDiscussion', + 'removePlaceholderNotes', + ]), + componentName(note) { + if (note.isPlaceholderNote) { + if (note.placeholderType === SYSTEM_NOTE) { + return placeholderSystemNote; + } + return placeholderNote; + } + + return issueNote; + }, + componentData(note) { + return note.isPlaceholderNote ? note.notes[0] : note; + }, + toggleDiscussionHandler() { + this.toggleDiscussion({ discussionId: this.note.id }); + }, + showReplyForm() { + this.isReplying = true; + }, + cancelReplyForm(shouldConfirm) { + if (shouldConfirm && this.$refs.noteForm.isDirty) { + // eslint-disable-next-line no-alert + if (!confirm('Are you sure you want to cancel creating this comment?')) { + return; + } + } + + this.resetAutoSave(); + this.isReplying = false; + }, + saveReply(noteText, form, callback) { + const replyData = { + endpoint: this.newNotePath, + flashContainer: this.$el, + data: { + in_reply_to_discussion_id: this.note.reply_id, + target_type: 'issue', + target_id: this.discussion.noteable_id, + note: { note: noteText }, + }, + }; + this.isReplying = false; + + this.saveNote(replyData) + .then(() => { + this.resetAutoSave(); + callback(); + }) + .catch((err) => { + this.removePlaceholderNotes(); + this.isReplying = true; + this.$nextTick(() => { + const msg = 'Your comment could not be submitted! Please check your network connection and try again.'; + Flash(msg, 'alert', $(this.$el)); + this.$refs.noteForm.note = noteText; + callback(err); + }); + }); + }, + }, + mounted() { + if (this.isReplying) { + this.initAutoSave(); + } + }, + updated() { + if (this.isReplying) { + if (!this.autosave) { + this.initAutoSave(); + } else { + this.setAutoSave(); + } + } + }, + }; +</script> + +<template> + <li class="note note-discussion timeline-entry"> + <div class="timeline-entry-inner"> + <div class="timeline-icon"> + <user-avatar-link + :link-href="author.path" + :img-src="author.avatar_url" + :img-alt="author.name" + :img-size="40" + /> + </div> + <div class="timeline-content"> + <div class="discussion"> + <div class="discussion-header"> + <issue-note-header + :author="author" + :created-at="discussion.created_at" + :note-id="discussion.id" + :include-toggle="true" + @toggleHandler="toggleDiscussionHandler" + action-text="started a discussion" + class="discussion" + /> + <issue-note-edited-text + v-if="lastUpdatedAt" + :edited-at="lastUpdatedAt" + :edited-by="lastUpdatedBy" + action-text="Last updated" + class-name="discussion-headline-light js-discussion-headline" + /> + </div> + </div> + <div + v-if="note.expanded" + class="discussion-body"> + <div class="panel panel-default"> + <div class="discussion-notes"> + <ul class="notes"> + <component + v-for="note in note.notes" + :is="componentName(note)" + :note="componentData(note)" + :key="note.id" + /> + </ul> + <div + :class="{ 'is-replying': isReplying }" + class="discussion-reply-holder"> + <button + v-if="canReply && !isReplying" + @click="showReplyForm" + type="button" + class="js-vue-discussion-reply btn btn-text-field" + title="Add a reply">Reply...</button> + <issue-note-form + v-if="isReplying" + save-button-title="Comment" + :discussion="note" + :is-editing="false" + @handleFormUpdate="saveReply" + @cancelFormEdition="cancelReplyForm" + ref="noteForm" + /> + <issue-note-signed-out-widget v-if="!canReply" /> + </div> + </div> + </div> + </div> + </div> + </div> + </div> + </li> +</template> diff --git a/app/assets/javascripts/notes/components/issue_note.vue b/app/assets/javascripts/notes/components/issue_note.vue new file mode 100644 index 00000000000..3483f6c7538 --- /dev/null +++ b/app/assets/javascripts/notes/components/issue_note.vue @@ -0,0 +1,186 @@ +<script> + /* global Flash */ + + import { mapGetters, mapActions } from 'vuex'; + import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; + import issueNoteHeader from './issue_note_header.vue'; + import issueNoteActions from './issue_note_actions.vue'; + import issueNoteBody from './issue_note_body.vue'; + import eventHub from '../event_hub'; + + export default { + props: { + note: { + type: Object, + required: true, + }, + }, + data() { + return { + isEditing: false, + isDeleting: false, + isRequesting: false, + }; + }, + components: { + userAvatarLink, + issueNoteHeader, + issueNoteActions, + issueNoteBody, + }, + computed: { + ...mapGetters([ + 'targetNoteHash', + 'getUserData', + ]), + author() { + return this.note.author; + }, + classNameBindings() { + return { + 'is-editing': this.isEditing && !this.isRequesting, + 'is-requesting being-posted': this.isRequesting, + 'disabled-content': this.isDeleting, + target: this.targetNoteHash === this.noteAnchorId, + }; + }, + canReportAsAbuse() { + return this.note.report_abuse_path && this.author.id !== this.getUserData.id; + }, + noteAnchorId() { + return `note_${this.note.id}`; + }, + }, + methods: { + ...mapActions([ + 'deleteNote', + 'updateNote', + 'scrollToNoteIfNeeded', + ]), + editHandler() { + this.isEditing = true; + }, + deleteHandler() { + // eslint-disable-next-line no-alert + if (confirm('Are you sure you want to delete this list?')) { + this.isDeleting = true; + + this.deleteNote(this.note) + .then(() => { + this.isDeleting = false; + }) + .catch(() => { + Flash('Something went wrong while deleting your note. Please try again.'); + this.isDeleting = false; + }); + } + }, + formUpdateHandler(noteText, parentElement, callback) { + const data = { + endpoint: this.note.path, + note: { + target_type: 'issue', + target_id: this.note.noteable_id, + note: { note: noteText }, + }, + }; + this.isRequesting = true; + this.oldContent = this.note.note_html; + this.note.note_html = noteText; + + this.updateNote(data) + .then(() => { + this.isEditing = false; + this.isRequesting = false; + $(this.$refs.noteBody.$el).renderGFM(); + this.$refs.noteBody.resetAutoSave(); + callback(); + }) + .catch(() => { + this.isRequesting = false; + this.isEditing = true; + this.$nextTick(() => { + const msg = 'Something went wrong while editing your comment. Please try again.'; + Flash(msg, 'alert', $(this.$el)); + this.recoverNoteContent(noteText); + callback(); + }); + }); + }, + formCancelHandler(shouldConfirm, isDirty) { + if (shouldConfirm && isDirty) { + // eslint-disable-next-line no-alert + if (!confirm('Are you sure you want to cancel editing this comment?')) return; + } + this.$refs.noteBody.resetAutoSave(); + if (this.oldContent) { + this.note.note_html = this.oldContent; + this.oldContent = null; + } + this.isEditing = false; + }, + recoverNoteContent(noteText) { + // we need to do this to prevent noteForm inconsistent content warning + // this is something we intentionally do so we need to recover the content + this.note.note = noteText; + this.$refs.noteBody.$refs.noteForm.note = noteText; // TODO: This could be better + }, + }, + created() { + eventHub.$on('enterEditMode', ({ noteId }) => { + if (noteId === this.note.id) { + this.isEditing = true; + this.scrollToNoteIfNeeded($(this.$el)); + } + }); + }, + }; +</script> + +<template> + <li + class="note timeline-entry" + :id="noteAnchorId" + :class="classNameBindings" + :data-award-url="note.toggle_award_path"> + <div class="timeline-entry-inner"> + <div class="timeline-icon"> + <user-avatar-link + :link-href="author.path" + :img-src="author.avatar_url" + :img-alt="author.name" + :img-size="40" + /> + </div> + <div class="timeline-content"> + <div class="note-header"> + <issue-note-header + :author="author" + :created-at="note.created_at" + :note-id="note.id" + action-text="commented" + /> + <issue-note-actions + :author-id="author.id" + :note-id="note.id" + :access-level="note.human_access" + :can-edit="note.current_user.can_edit" + :can-delete="note.current_user.can_edit" + :can-report-as-abuse="canReportAsAbuse" + :report-abuse-path="note.report_abuse_path" + @handleEdit="editHandler" + @handleDelete="deleteHandler" + /> + </div> + <issue-note-body + :note="note" + :can-edit="note.current_user.can_edit" + :is-editing="isEditing" + @handleFormUpdate="formUpdateHandler" + @cancelFormEdition="formCancelHandler" + ref="noteBody" + /> + </div> + </div> + </li> +</template> diff --git a/app/assets/javascripts/notes/components/issue_note_actions.vue b/app/assets/javascripts/notes/components/issue_note_actions.vue new file mode 100644 index 00000000000..60c172321d1 --- /dev/null +++ b/app/assets/javascripts/notes/components/issue_note_actions.vue @@ -0,0 +1,167 @@ +<script> + import { mapGetters } from 'vuex'; + import emojiSmiling from 'icons/_emoji_slightly_smiling_face.svg'; + import emojiSmile from 'icons/_emoji_smile.svg'; + import emojiSmiley from 'icons/_emoji_smiley.svg'; + import editSvg from 'icons/_icon_pencil.svg'; + import ellipsisSvg from 'icons/_ellipsis_v.svg'; + import loadingIcon from '../../vue_shared/components/loading_icon.vue'; + import tooltip from '../../vue_shared/directives/tooltip'; + + export default { + name: 'issueNoteActions', + props: { + authorId: { + type: Number, + required: true, + }, + noteId: { + type: Number, + required: true, + }, + accessLevel: { + type: String, + required: false, + default: '', + }, + reportAbusePath: { + type: String, + required: true, + }, + canEdit: { + type: Boolean, + required: true, + }, + canDelete: { + type: Boolean, + required: true, + }, + canReportAsAbuse: { + type: Boolean, + required: true, + }, + }, + directives: { + tooltip, + }, + components: { + loadingIcon, + }, + computed: { + ...mapGetters([ + 'getUserDataByProp', + ]), + shouldShowActionsDropdown() { + return this.currentUserId && (this.canEdit || this.canReportAsAbuse); + }, + canAddAwardEmoji() { + return this.currentUserId; + }, + isAuthoredByCurrentUser() { + return this.authorId === this.currentUserId; + }, + currentUserId() { + return this.getUserDataByProp('id'); + }, + }, + methods: { + onEdit() { + this.$emit('handleEdit'); + }, + onDelete() { + this.$emit('handleDelete'); + }, + }, + created() { + this.emojiSmiling = emojiSmiling; + this.emojiSmile = emojiSmile; + this.emojiSmiley = emojiSmiley; + this.editSvg = editSvg; + this.ellipsisSvg = ellipsisSvg; + }, + }; +</script> + +<template> + <div class="note-actions"> + <span + v-if="accessLevel" + class="note-role">{{accessLevel}}</span> + <div + v-if="canAddAwardEmoji" + class="note-actions-item"> + <a + v-tooltip + :class="{ 'js-user-authored': isAuthoredByCurrentUser }" + class="note-action-button note-emoji-button js-add-award js-note-emoji" + data-position="right" + data-placement="bottom" + data-container="body" + href="#" + title="Add reaction"> + <loading-icon :inline="true" /> + <span + v-html="emojiSmiling" + class="link-highlight award-control-icon-neutral"> + </span> + <span + v-html="emojiSmiley" + class="link-highlight award-control-icon-positive"> + </span> + <span + v-html="emojiSmile" + class="link-highlight award-control-icon-super-positive"> + </span> + </a> + </div> + <div + v-if="canEdit" + class="note-actions-item"> + <button + @click="onEdit" + v-tooltip + type="button" + title="Edit comment" + class="note-action-button js-note-edit btn btn-transparent" + data-container="body" + data-placement="bottom"> + <span + v-html="editSvg" + class="link-highlight"></span> + </button> + </div> + <div + v-if="shouldShowActionsDropdown" + class="dropdown more-actions note-actions-item"> + <button + v-tooltip + type="button" + title="More actions" + class="note-action-button more-actions-toggle btn btn-transparent" + data-toggle="dropdown" + data-container="body" + data-placement="bottom"> + <span + class="icon" + v-html="ellipsisSvg"></span> + </button> + <ul class="dropdown-menu more-actions-dropdown dropdown-open-left"> + <li v-if="canReportAsAbuse"> + <a :href="reportAbusePath"> + Report as abuse + </a> + </li> + <li v-if="canEdit"> + <button + @click.prevent="onDelete" + class="btn btn-transparent js-note-delete js-note-delete" + type="button"> + <span class="text-danger"> + Delete comment + </span> + </button> + </li> + </ul> + </div> + </div> +</template> diff --git a/app/assets/javascripts/notes/components/issue_note_attachment.vue b/app/assets/javascripts/notes/components/issue_note_attachment.vue new file mode 100644 index 00000000000..7134a3eb47e --- /dev/null +++ b/app/assets/javascripts/notes/components/issue_note_attachment.vue @@ -0,0 +1,37 @@ +<script> + export default { + name: 'issueNoteAttachment', + props: { + attachment: { + type: Object, + required: true, + }, + }, + }; +</script> + +<template> + <div class="note-attachment"> + <a + v-if="attachment.image" + :href="attachment.url" + target="_blank" + rel="noopener noreferrer"> + <img + :src="attachment.url" + class="note-image-attach" /> + </a> + <div class="attachment"> + <a + v-if="attachment.url" + :href="attachment.url" + target="_blank" + rel="noopener noreferrer"> + <i + class="fa fa-paperclip" + aria-hidden="true"></i> + {{attachment.filename}} + </a> + </div> + </div> +</template> diff --git a/app/assets/javascripts/notes/components/issue_note_awards_list.vue b/app/assets/javascripts/notes/components/issue_note_awards_list.vue new file mode 100644 index 00000000000..d42e61e3899 --- /dev/null +++ b/app/assets/javascripts/notes/components/issue_note_awards_list.vue @@ -0,0 +1,228 @@ +<script> + /* global Flash */ + + import { mapActions, mapGetters } from 'vuex'; + import emojiSmiling from 'icons/_emoji_slightly_smiling_face.svg'; + import emojiSmile from 'icons/_emoji_smile.svg'; + import emojiSmiley from 'icons/_emoji_smiley.svg'; + import { glEmojiTag } from '../../emoji'; + import tooltip from '../../vue_shared/directives/tooltip'; + + export default { + props: { + awards: { + type: Array, + required: true, + }, + toggleAwardPath: { + type: String, + required: true, + }, + noteAuthorId: { + type: Number, + required: true, + }, + noteId: { + type: Number, + required: true, + }, + }, + directives: { + tooltip, + }, + computed: { + ...mapGetters([ + 'getUserData', + ]), + // `this.awards` is an array with emojis but they are not grouped by emoji name. See below. + // [ { name: foo, user: user1 }, { name: bar, user: user1 }, { name: foo, user: user2 } ] + // This method will group emojis by their name as an Object. See below. + // { + // foo: [ { name: foo, user: user1 }, { name: foo, user: user2 } ], + // bar: [ { name: bar, user: user1 } ] + // } + // We need to do this otherwise we will render the same emoji over and over again. + groupedAwards() { + const awards = this.awards.reduce((acc, award) => { + if (Object.prototype.hasOwnProperty.call(acc, award.name)) { + acc[award.name].push(award); + } else { + Object.assign(acc, { [award.name]: [award] }); + } + + return acc; + }, {}); + + const orderedAwards = {}; + const { thumbsdown, thumbsup } = awards; + // Always show thumbsup and thumbsdown first + if (thumbsup) { + orderedAwards.thumbsup = thumbsup; + delete awards.thumbsup; + } + if (thumbsdown) { + orderedAwards.thumbsdown = thumbsdown; + delete awards.thumbsdown; + } + + return Object.assign({}, orderedAwards, awards); + }, + isAuthoredByMe() { + return this.noteAuthorId === this.getUserData.id; + }, + isLoggedIn() { + return this.getUserData.id; + }, + }, + methods: { + ...mapActions([ + 'toggleAwardRequest', + ]), + getAwardHTML(name) { + return glEmojiTag(name); + }, + getAwardClassBindings(awardList, awardName) { + return { + active: this.hasReactionByCurrentUser(awardList), + disabled: !this.canInteractWithEmoji(awardList, awardName), + }; + }, + canInteractWithEmoji(awardList, awardName) { + let isAllowed = true; + const restrictedEmojis = ['thumbsup', 'thumbsdown']; + + // Users can not add :+1: and :-1: to their own notes + if (this.getUserData.id === this.noteAuthorId && restrictedEmojis.indexOf(awardName) > -1) { + isAllowed = false; + } + + return this.getUserData.id && isAllowed; + }, + hasReactionByCurrentUser(awardList) { + return awardList.filter(award => award.user.id === this.getUserData.id).length; + }, + awardTitle(awardsList) { + const hasReactionByCurrentUser = this.hasReactionByCurrentUser(awardsList); + const TOOLTIP_NAME_COUNT = hasReactionByCurrentUser ? 9 : 10; + let awardList = awardsList; + + // Filter myself from list if I am awarded. + if (hasReactionByCurrentUser) { + awardList = awardList.filter(award => award.user.id !== this.getUserData.id); + } + + // Get only 9-10 usernames to show in tooltip text. + const namesToShow = awardList.slice(0, TOOLTIP_NAME_COUNT).map(award => award.user.name); + + // Get the remaining list to use in `and x more` text. + const remainingAwardList = awardList.slice(TOOLTIP_NAME_COUNT, awardList.length); + + // Add myself to the begining of the list so title will start with You. + if (hasReactionByCurrentUser) { + namesToShow.unshift('You'); + } + + let title = ''; + + // We have 10+ awarded user, join them with comma and add `and x more`. + if (remainingAwardList.length) { + title = `${namesToShow.join(', ')}, and ${remainingAwardList.length} more.`; + } else if (namesToShow.length > 1) { + // Join all names with comma but not the last one, it will be added with and text. + title = namesToShow.slice(0, namesToShow.length - 1).join(', '); + // If we have more than 2 users we need an extra comma before and text. + title += namesToShow.length > 2 ? ',' : ''; + title += ` and ${namesToShow.slice(-1)}`; // Append and text + } else { // We have only 2 users so join them with and. + title = namesToShow.join(' and '); + } + + return title; + }, + handleAward(awardName) { + if (!this.isLoggedIn) { + return; + } + + let parsedName; + + // 100 and 1234 emoji are a number. Callback for v-for click sends it as a string + switch (awardName) { + case '100': + parsedName = 100; + break; + case '1234': + parsedName = 1234; + break; + default: + parsedName = awardName; + break; + } + + const data = { + endpoint: this.toggleAwardPath, + noteId: this.noteId, + awardName: parsedName, + }; + + this.toggleAwardRequest(data) + .catch(() => Flash('Something went wrong on our end.')); + }, + }, + created() { + this.emojiSmiling = emojiSmiling; + this.emojiSmile = emojiSmile; + this.emojiSmiley = emojiSmiley; + }, + }; +</script> + +<template> + <div class="note-awards"> + <div class="awards js-awards-block"> + <button + v-tooltip + v-for="(awardList, awardName, index) in groupedAwards" + :key="index" + :class="getAwardClassBindings(awardList, awardName)" + :title="awardTitle(awardList)" + @click="handleAward(awardName)" + class="btn award-control" + data-placement="bottom" + type="button"> + <span v-html="getAwardHTML(awardName)"></span> + <span class="award-control-text js-counter"> + {{awardList.length}} + </span> + </button> + <div + v-if="isLoggedIn" + class="award-menu-holder"> + <button + v-tooltip + :class="{ 'js-user-authored': isAuthoredByMe }" + class="award-control btn js-add-award" + title="Add reaction" + aria-label="Add reaction" + data-placement="bottom" + type="button"> + <span + v-html="emojiSmiling" + class="award-control-icon award-control-icon-neutral"> + </span> + <span + v-html="emojiSmiley" + class="award-control-icon award-control-icon-positive"> + </span> + <span + v-html="emojiSmile" + class="award-control-icon award-control-icon-super-positive"> + </span> + <i + aria-hidden="true" + class="fa fa-spinner fa-spin award-control-icon award-control-icon-loading"></i> + </button> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/notes/components/issue_note_body.vue b/app/assets/javascripts/notes/components/issue_note_body.vue new file mode 100644 index 00000000000..5f9003bfd87 --- /dev/null +++ b/app/assets/javascripts/notes/components/issue_note_body.vue @@ -0,0 +1,122 @@ +<script> + import issueNoteEditedText from './issue_note_edited_text.vue'; + import issueNoteAwardsList from './issue_note_awards_list.vue'; + import issueNoteAttachment from './issue_note_attachment.vue'; + import issueNoteForm from './issue_note_form.vue'; + import TaskList from '../../task_list'; + import autosave from '../mixins/autosave'; + + export default { + props: { + note: { + type: Object, + required: true, + }, + canEdit: { + type: Boolean, + required: true, + }, + isEditing: { + type: Boolean, + required: false, + default: false, + }, + }, + mixins: [ + autosave, + ], + components: { + issueNoteEditedText, + issueNoteAwardsList, + issueNoteAttachment, + issueNoteForm, + }, + computed: { + noteBody() { + return this.note.note; + }, + }, + methods: { + renderGFM() { + $(this.$refs['note-body']).renderGFM(); + }, + initTaskList() { + if (this.canEdit) { + this.taskList = new TaskList({ + dataType: 'note', + fieldName: 'note', + selector: '.notes', + }); + } + }, + handleFormUpdate(note, parentElement, callback) { + this.$emit('handleFormUpdate', note, parentElement, callback); + }, + formCancelHandler(shouldConfirm, isDirty) { + this.$emit('cancelFormEdition', shouldConfirm, isDirty); + }, + }, + mounted() { + this.renderGFM(); + this.initTaskList(); + + if (this.isEditing) { + this.initAutoSave(); + } + }, + updated() { + this.initTaskList(); + this.renderGFM(); + + if (this.isEditing) { + if (!this.autosave) { + this.initAutoSave(); + } else { + this.setAutoSave(); + } + } + }, + }; +</script> + +<template> + <div + :class="{ 'js-task-list-container': canEdit }" + ref="note-body" + class="note-body"> + <div + v-html="note.note_html" + class="note-text md"></div> + <issue-note-form + v-if="isEditing" + ref="noteForm" + @handleFormUpdate="handleFormUpdate" + @cancelFormEdition="formCancelHandler" + :is-editing="isEditing" + :note-body="noteBody" + :note-id="note.id" + /> + <textarea + v-if="canEdit" + v-model="note.note" + :data-update-url="note.path" + class="hidden js-task-list-field"></textarea> + <issue-note-edited-text + v-if="note.last_edited_at" + :edited-at="note.last_edited_at" + :edited-by="note.last_edited_by" + action-text="Edited" + /> + <issue-note-awards-list + v-if="note.award_emoji.length" + :note-id="note.id" + :note-author-id="note.author.id" + :awards="note.award_emoji" + :toggle-award-path="note.toggle_award_path" + /> + <issue-note-attachment + v-if="note.attachment" + :attachment="note.attachment" + /> + </div> +</template> diff --git a/app/assets/javascripts/notes/components/issue_note_edited_text.vue b/app/assets/javascripts/notes/components/issue_note_edited_text.vue new file mode 100644 index 00000000000..49e09f0ecc5 --- /dev/null +++ b/app/assets/javascripts/notes/components/issue_note_edited_text.vue @@ -0,0 +1,47 @@ +<script> + import timeAgoTooltip from '../../vue_shared/components/time_ago_tooltip.vue'; + + export default { + name: 'editedNoteText', + props: { + actionText: { + type: String, + required: true, + }, + editedAt: { + type: String, + required: true, + }, + editedBy: { + type: Object, + required: false, + }, + className: { + type: String, + required: false, + default: 'edited-text', + }, + }, + components: { + timeAgoTooltip, + }, + }; +</script> + +<template> + <div :class="className"> + {{actionText}} + <time-ago-tooltip + :time="editedAt" + tooltip-placement="bottom" + /> + <template v-if="editedBy"> + by + <a + :href="editedBy.path" + class="js-vue-author author_link"> + {{editedBy.name}} + </a> + </template> + </div> +</template> diff --git a/app/assets/javascripts/notes/components/issue_note_form.vue b/app/assets/javascripts/notes/components/issue_note_form.vue new file mode 100644 index 00000000000..626c0f2ce18 --- /dev/null +++ b/app/assets/javascripts/notes/components/issue_note_form.vue @@ -0,0 +1,166 @@ +<script> + import { mapGetters } from 'vuex'; + import eventHub from '../event_hub'; + import confidentialIssue from '../../vue_shared/components/issue/confidential_issue_warning.vue'; + import markdownField from '../../vue_shared/components/markdown/field.vue'; + + export default { + name: 'issueNoteForm', + props: { + noteBody: { + type: String, + required: false, + default: '', + }, + noteId: { + type: Number, + required: false, + }, + saveButtonTitle: { + type: String, + required: false, + default: 'Save comment', + }, + discussion: { + type: Object, + required: false, + default: () => ({}), + }, + isEditing: { + type: Boolean, + required: true, + }, + }, + data() { + return { + note: this.noteBody, + conflictWhileEditing: false, + isSubmitting: false, + }; + }, + components: { + confidentialIssue, + markdownField, + }, + computed: { + ...mapGetters([ + 'getDiscussionLastNote', + 'getIssueDataByProp', + 'getNotesDataByProp', + 'getUserDataByProp', + ]), + noteHash() { + return `#note_${this.noteId}`; + }, + markdownPreviewPath() { + return this.getIssueDataByProp('preview_note_path'); + }, + markdownDocsPath() { + return this.getNotesDataByProp('markdownDocsPath'); + }, + quickActionsDocsPath() { + return !this.isEditing ? this.getNotesDataByProp('quickActionsDocsPath') : undefined; + }, + currentUserId() { + return this.getUserDataByProp('id'); + }, + isDisabled() { + return !this.note.length || this.isSubmitting; + }, + isConfidentialIssue() { + return this.getIssueDataByProp('confidential'); + }, + }, + methods: { + handleUpdate() { + this.isSubmitting = true; + + this.$emit('handleFormUpdate', this.note, this.$refs.editNoteForm, () => { + this.isSubmitting = false; + }); + }, + editMyLastNote() { + if (this.note === '') { + const lastNoteInDiscussion = this.getDiscussionLastNote(this.discussion); + + if (lastNoteInDiscussion) { + eventHub.$emit('enterEditMode', { + noteId: lastNoteInDiscussion.id, + }); + } + } + }, + cancelHandler(shouldConfirm = false) { + // Sends information about confirm message and if the textarea has changed + this.$emit('cancelFormEdition', shouldConfirm, this.noteBody !== this.note); + }, + }, + mounted() { + this.$refs.textarea.focus(); + }, + watch: { + noteBody() { + if (this.note === this.noteBody) { + this.note = this.noteBody; + } else { + this.conflictWhileEditing = true; + } + }, + }, + }; +</script> + +<template> + <div ref="editNoteForm" class="note-edit-form current-note-edit-form"> + <div + v-if="conflictWhileEditing" + class="js-conflict-edit-warning alert alert-danger"> + This comment has changed since you started editing, please review the + <a + :href="noteHash" + target="_blank" + rel="noopener noreferrer">updated comment</a> + to ensure information is not lost. + </div> + <div class="flash-container timeline-content"></div> + <form + class="edit-note common-note-form js-quick-submit gfm-form"> + <confidentialIssue v-if="isConfidentialIssue" /> + <markdown-field + :markdown-preview-path="markdownPreviewPath" + :markdown-docs-path="markdownDocsPath" + :quick-actions-docs-path="quickActionsDocsPath" + :add-spacing-classes="false"> + <textarea + id="note_note" + name="note[note]" + class="note-textarea js-gfm-input js-autosize markdown-area js-vue-issue-note-form js-vue-textarea" + :data-supports-quick-actions="!isEditing" + aria-label="Description" + v-model="note" + ref="textarea" + slot="textarea" + placeholder="Write a comment or drag your files here..." + @keydown.meta.enter="handleUpdate()" + @keydown.up="editMyLastNote()" + @keydown.esc="cancelHandler(true)"> + </textarea> + </markdown-field> + <div class="note-form-actions clearfix"> + <button + type="button" + @click="handleUpdate()" + :disabled="isDisabled" + class="js-vue-issue-save btn btn-save"> + {{saveButtonTitle}} + </button> + <button + @click="cancelHandler()" + class="btn btn-cancel note-edit-cancel" + type="button"> + Cancel + </button> + </div> + </form> + </div> +</template> diff --git a/app/assets/javascripts/notes/components/issue_note_header.vue b/app/assets/javascripts/notes/components/issue_note_header.vue new file mode 100644 index 00000000000..63aa3d777d0 --- /dev/null +++ b/app/assets/javascripts/notes/components/issue_note_header.vue @@ -0,0 +1,118 @@ +<script> + import { mapActions } from 'vuex'; + import timeAgoTooltip from '../../vue_shared/components/time_ago_tooltip.vue'; + + export default { + props: { + author: { + type: Object, + required: true, + }, + createdAt: { + type: String, + required: true, + }, + actionText: { + type: String, + required: false, + default: '', + }, + actionTextHtml: { + type: String, + required: false, + default: '', + }, + noteId: { + type: Number, + required: true, + }, + includeToggle: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + isExpanded: true, + }; + }, + components: { + timeAgoTooltip, + }, + computed: { + toggleChevronClass() { + return this.isExpanded ? 'fa-chevron-up' : 'fa-chevron-down'; + }, + noteTimestampLink() { + return `#note_${this.noteId}`; + }, + }, + methods: { + ...mapActions([ + 'setTargetNoteHash', + ]), + handleToggle() { + this.isExpanded = !this.isExpanded; + this.$emit('toggleHandler'); + }, + updateTargetNoteHash() { + this.setTargetNoteHash(this.noteTimestampLink); + }, + }, + }; +</script> + +<template> + <div class="note-header-info"> + <a :href="author.path"> + <span class="note-header-author-name"> + {{author.name}} + </span> + <span class="note-headline-light"> + @{{author.username}} + </span> + </a> + <span class="note-headline-light"> + <span class="note-headline-meta"> + <template v-if="actionText"> + {{actionText}} + </template> + <span + v-if="actionTextHtml" + v-html="actionTextHtml" + class="system-note-message"> + </span> + <a + :href="noteTimestampLink" + @click="updateTargetNoteHash" + class="note-timestamp"> + <time-ago-tooltip + :time="createdAt" + tooltip-placement="bottom" + /> + </a> + <i + class="fa fa-spinner fa-spin editing-spinner" + aria-label="Comment is being updated" + aria-hidden="true"> + </i> + </span> + </span> + <div + v-if="includeToggle" + class="discussion-actions"> + <button + @click="handleToggle" + class="note-action-button discussion-toggle-button js-vue-toggle-button" + type="button"> + <i + :class="toggleChevronClass" + class="fa" + aria-hidden="true"> + </i> + Toggle discussion + </button> + </div> + </div> +</template> diff --git a/app/assets/javascripts/notes/components/issue_note_icons.js b/app/assets/javascripts/notes/components/issue_note_icons.js new file mode 100644 index 00000000000..d8e3cb4bc01 --- /dev/null +++ b/app/assets/javascripts/notes/components/issue_note_icons.js @@ -0,0 +1,37 @@ +import iconArrowCircle from 'icons/_icon_arrow_circle_o_right.svg'; +import iconCheck from 'icons/_icon_check_square_o.svg'; +import iconClock from 'icons/_icon_clock_o.svg'; +import iconCodeFork from 'icons/_icon_code_fork.svg'; +import iconComment from 'icons/_icon_comment_o.svg'; +import iconCommit from 'icons/_icon_commit.svg'; +import iconEdit from 'icons/_icon_edit.svg'; +import iconEye from 'icons/_icon_eye.svg'; +import iconEyeSlash from 'icons/_icon_eye_slash.svg'; +import iconMerge from 'icons/_icon_merge.svg'; +import iconMerged from 'icons/_icon_merged.svg'; +import iconRandom from 'icons/_icon_random.svg'; +import iconClosed from 'icons/_icon_status_closed.svg'; +import iconStatusOpen from 'icons/_icon_status_open.svg'; +import iconStopwatch from 'icons/_icon_stopwatch.svg'; +import iconTags from 'icons/_icon_tags.svg'; +import iconUser from 'icons/_icon_user.svg'; + +export default { + icon_arrow_circle_o_right: iconArrowCircle, + icon_check_square_o: iconCheck, + icon_clock_o: iconClock, + icon_code_fork: iconCodeFork, + icon_comment_o: iconComment, + icon_commit: iconCommit, + icon_edit: iconEdit, + icon_eye: iconEye, + icon_eye_slash: iconEyeSlash, + icon_merge: iconMerge, + icon_merged: iconMerged, + icon_random: iconRandom, + icon_status_closed: iconClosed, + icon_status_open: iconStatusOpen, + icon_stopwatch: iconStopwatch, + icon_tags: iconTags, + icon_user: iconUser, +}; diff --git a/app/assets/javascripts/notes/components/issue_note_signed_out_widget.vue b/app/assets/javascripts/notes/components/issue_note_signed_out_widget.vue new file mode 100644 index 00000000000..77af3594c1c --- /dev/null +++ b/app/assets/javascripts/notes/components/issue_note_signed_out_widget.vue @@ -0,0 +1,28 @@ +<script> + import { mapGetters } from 'vuex'; + + export default { + name: 'singInLinksNotes', + computed: { + ...mapGetters([ + 'getNotesDataByProp', + ]), + registerLink() { + return this.getNotesDataByProp('registerPath'); + }, + signInLink() { + return this.getNotesDataByProp('newSessionPath'); + }, + }, + }; +</script> + +<template> + <div class="disabled-comment text-center"> + Please + <a :href="registerLink">register</a> + or + <a :href="signInLink">sign in</a> + to reply + </div> +</template> diff --git a/app/assets/javascripts/notes/components/issue_notes_app.vue b/app/assets/javascripts/notes/components/issue_notes_app.vue new file mode 100644 index 00000000000..b6fc5e5036f --- /dev/null +++ b/app/assets/javascripts/notes/components/issue_notes_app.vue @@ -0,0 +1,151 @@ +<script> + /* global Flash */ + import { mapGetters, mapActions } from 'vuex'; + import store from '../stores/'; + import * as constants from '../constants'; + import issueNote from './issue_note.vue'; + import issueDiscussion from './issue_discussion.vue'; + import issueSystemNote from './issue_system_note.vue'; + import issueCommentForm from './issue_comment_form.vue'; + import placeholderNote from './issue_placeholder_note.vue'; + import placeholderSystemNote from './issue_placeholder_system_note.vue'; + import loadingIcon from '../../vue_shared/components/loading_icon.vue'; + + export default { + name: 'issueNotesApp', + props: { + issueData: { + type: Object, + required: true, + }, + notesData: { + type: Object, + required: true, + }, + userData: { + type: Object, + required: false, + default: {}, + }, + }, + store, + data() { + return { + isLoading: true, + }; + }, + components: { + issueNote, + issueDiscussion, + issueSystemNote, + issueCommentForm, + loadingIcon, + placeholderNote, + placeholderSystemNote, + }, + computed: { + ...mapGetters([ + 'notes', + 'getNotesDataByProp', + ]), + }, + methods: { + ...mapActions({ + actionFetchNotes: 'fetchNotes', + poll: 'poll', + actionToggleAward: 'toggleAward', + scrollToNoteIfNeeded: 'scrollToNoteIfNeeded', + setNotesData: 'setNotesData', + setIssueData: 'setIssueData', + setUserData: 'setUserData', + setLastFetchedAt: 'setLastFetchedAt', + setTargetNoteHash: 'setTargetNoteHash', + }), + getComponentName(note) { + if (note.isPlaceholderNote) { + if (note.placeholderType === constants.SYSTEM_NOTE) { + return placeholderSystemNote; + } + return placeholderNote; + } else if (note.individual_note) { + return note.notes[0].system ? issueSystemNote : issueNote; + } + + return issueDiscussion; + }, + getComponentData(note) { + return note.individual_note ? note.notes[0] : note; + }, + fetchNotes() { + return this.actionFetchNotes(this.getNotesDataByProp('discussionsPath')) + .then(() => this.initPolling()) + .then(() => { + this.isLoading = false; + }) + .then(() => this.$nextTick()) + .then(() => this.checkLocationHash()) + .catch(() => { + this.isLoading = false; + Flash('Something went wrong while fetching issue comments. Please try again.'); + }); + }, + initPolling() { + this.setLastFetchedAt(this.getNotesDataByProp('lastFetchedAt')); + + this.poll(); + }, + checkLocationHash() { + const hash = gl.utils.getLocationHash(); + const element = document.getElementById(hash); + + if (hash && element) { + this.setTargetNoteHash(hash); + this.scrollToNoteIfNeeded($(element)); + } + }, + }, + created() { + this.setNotesData(this.notesData); + this.setIssueData(this.issueData); + this.setUserData(this.userData); + }, + mounted() { + this.fetchNotes(); + + const parentElement = this.$el.parentElement; + + if (parentElement && + parentElement.classList.contains('js-vue-notes-event')) { + parentElement.addEventListener('toggleAward', (event) => { + const { awardName, noteId } = event.detail; + this.actionToggleAward({ awardName, noteId }); + }); + } + }, + }; +</script> + +<template> + <div id="notes"> + <div + v-if="isLoading" + class="js-loading loading"> + <loading-icon /> + </div> + + <ul + v-if="!isLoading" + id="notes-list" + class="notes main-notes-list timeline"> + + <component + v-for="note in notes" + :is="getComponentName(note)" + :note="getComponentData(note)" + :key="note.id" + /> + </ul> + + <issue-comment-form /> + </div> +</template> diff --git a/app/assets/javascripts/notes/components/issue_placeholder_note.vue b/app/assets/javascripts/notes/components/issue_placeholder_note.vue new file mode 100644 index 00000000000..6921d91372f --- /dev/null +++ b/app/assets/javascripts/notes/components/issue_placeholder_note.vue @@ -0,0 +1,53 @@ +<script> + import { mapGetters } from 'vuex'; + import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; + + export default { + name: 'issuePlaceholderNote', + props: { + note: { + type: Object, + required: true, + }, + }, + components: { + userAvatarLink, + }, + computed: { + ...mapGetters([ + 'getUserData', + ]), + }, + }; +</script> + +<template> + <li class="note being-posted fade-in-half timeline-entry"> + <div class="timeline-entry-inner"> + <div class="timeline-icon"> + <user-avatar-link + :link-href="getUserData.path" + :img-src="getUserData.avatar_url" + :img-size="40" + /> + </div> + <div + :class="{ discussion: !note.individual_note }" + class="timeline-content"> + <div class="note-header"> + <div class="note-header-info"> + <a :href="getUserData.path"> + <span class="hidden-xs">{{getUserData.name}}</span> + <span class="note-headline-light">@{{getUserData.username}}</span> + </a> + </div> + </div> + <div class="note-body"> + <div class="note-text"> + <p>{{note.body}}</p> + </div> + </div> + </div> + </div> + </li> +</template> diff --git a/app/assets/javascripts/notes/components/issue_placeholder_system_note.vue b/app/assets/javascripts/notes/components/issue_placeholder_system_note.vue new file mode 100644 index 00000000000..80a8ef56a83 --- /dev/null +++ b/app/assets/javascripts/notes/components/issue_placeholder_system_note.vue @@ -0,0 +1,21 @@ +<script> + export default { + name: 'placeholderSystemNote', + props: { + note: { + type: Object, + required: true, + }, + }, + }; +</script> + +<template> + <li class="note system-note timeline-entry being-posted fade-in-half"> + <div class="timeline-entry-inner"> + <div class="timeline-content"> + <em>{{note.body}}</em> + </div> + </div> + </li> +</template> diff --git a/app/assets/javascripts/notes/components/issue_system_note.vue b/app/assets/javascripts/notes/components/issue_system_note.vue new file mode 100644 index 00000000000..5bb8f871b9d --- /dev/null +++ b/app/assets/javascripts/notes/components/issue_system_note.vue @@ -0,0 +1,55 @@ +<script> + import { mapGetters } from 'vuex'; + import iconsMap from './issue_note_icons'; + import issueNoteHeader from './issue_note_header.vue'; + + export default { + name: 'systemNote', + props: { + note: { + type: Object, + required: true, + }, + }, + components: { + issueNoteHeader, + }, + computed: { + ...mapGetters([ + 'targetNoteHash', + ]), + noteAnchorId() { + return `note_${this.note.id}`; + }, + isTargetNote() { + return this.targetNoteHash === this.noteAnchorId; + }, + }, + created() { + this.svg = iconsMap[this.note.system_note_icon_name]; + }, + }; +</script> + +<template> + <li + :id="noteAnchorId" + :class="{ target: isTargetNote }" + class="note system-note timeline-entry"> + <div class="timeline-entry-inner"> + <div + class="timeline-icon" + v-html="svg"> + </div> + <div class="timeline-content"> + <div class="note-header"> + <issue-note-header + :author="note.author" + :created-at="note.created_at" + :note-id="note.id" + :action-text-html="note.note_html" /> + </div> + </div> + </div> + </li> +</template> diff --git a/app/assets/javascripts/notes/constants.js b/app/assets/javascripts/notes/constants.js new file mode 100644 index 00000000000..a6961063c01 --- /dev/null +++ b/app/assets/javascripts/notes/constants.js @@ -0,0 +1,11 @@ +export const DISCUSSION_NOTE = 'DiscussionNote'; +export const DISCUSSION = 'discussion'; +export const NOTE = 'note'; +export const SYSTEM_NOTE = 'systemNote'; +export const COMMENT = 'comment'; +export const OPENED = 'opened'; +export const REOPENED = 'reopened'; +export const CLOSED = 'closed'; +export const EMOJI_THUMBSUP = 'thumbsup'; +export const EMOJI_THUMBSDOWN = 'thumbsdown'; +export const NOTEABLE_TYPE = 'Issue'; diff --git a/app/assets/javascripts/notes/event_hub.js b/app/assets/javascripts/notes/event_hub.js new file mode 100644 index 00000000000..0948c2e5352 --- /dev/null +++ b/app/assets/javascripts/notes/event_hub.js @@ -0,0 +1,3 @@ +import Vue from 'vue'; + +export default new Vue(); diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js new file mode 100644 index 00000000000..e2ea37408cf --- /dev/null +++ b/app/assets/javascripts/notes/index.js @@ -0,0 +1,35 @@ +import Vue from 'vue'; +import issueNotesApp from './components/issue_notes_app.vue'; + +document.addEventListener('DOMContentLoaded', () => new Vue({ + el: '#js-vue-notes', + components: { + issueNotesApp, + }, + data() { + const notesDataset = document.getElementById('js-vue-notes').dataset; + + return { + issueData: JSON.parse(notesDataset.issueData), + currentUserData: JSON.parse(notesDataset.currentUserData), + notesData: { + lastFetchedAt: notesDataset.lastFetchedAt, + discussionsPath: notesDataset.discussionsPath, + newSessionPath: notesDataset.newSessionPath, + registerPath: notesDataset.registerPath, + notesPath: notesDataset.notesPath, + markdownDocsPath: notesDataset.markdownDocsPath, + quickActionsDocsPath: notesDataset.quickActionsDocsPath, + }, + }; + }, + render(createElement) { + return createElement('issue-notes-app', { + props: { + issueData: this.issueData, + notesData: this.notesData, + userData: this.currentUserData, + }, + }); + }, +})); diff --git a/app/assets/javascripts/notes/mixins/autosave.js b/app/assets/javascripts/notes/mixins/autosave.js new file mode 100644 index 00000000000..5843b97f225 --- /dev/null +++ b/app/assets/javascripts/notes/mixins/autosave.js @@ -0,0 +1,16 @@ +/* globals Autosave */ +import '../../autosave'; + +export default { + methods: { + initAutoSave() { + this.autosave = new Autosave($(this.$refs.noteForm.$refs.textarea), ['Note', 'Issue', this.note.id], 'issue'); + }, + resetAutoSave() { + this.autosave.reset(); + }, + setAutoSave() { + this.autosave.save(); + }, + }, +}; diff --git a/app/assets/javascripts/notes/services/issue_notes_service.js b/app/assets/javascripts/notes/services/issue_notes_service.js new file mode 100644 index 00000000000..b51b0cb2013 --- /dev/null +++ b/app/assets/javascripts/notes/services/issue_notes_service.js @@ -0,0 +1,35 @@ +import Vue from 'vue'; +import VueResource from 'vue-resource'; + +Vue.use(VueResource); + +export default { + fetchNotes(endpoint) { + return Vue.http.get(endpoint); + }, + deleteNote(endpoint) { + return Vue.http.delete(endpoint); + }, + replyToDiscussion(endpoint, data) { + return Vue.http.post(endpoint, data, { emulateJSON: true }); + }, + updateNote(endpoint, data) { + return Vue.http.put(endpoint, data, { emulateJSON: true }); + }, + createNewNote(endpoint, data) { + return Vue.http.post(endpoint, data, { emulateJSON: true }); + }, + poll(data = {}) { + const { endpoint, lastFetchedAt } = data; + const options = { + headers: { + 'X-Last-Fetched-At': lastFetchedAt, + }, + }; + + return Vue.http.get(endpoint, options); + }, + toggleAward(endpoint, data) { + return Vue.http.post(endpoint, data, { emulateJSON: true }); + }, +}; diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js new file mode 100644 index 00000000000..13cd74bfa1c --- /dev/null +++ b/app/assets/javascripts/notes/stores/actions.js @@ -0,0 +1,217 @@ +/* global Flash */ +import Visibility from 'visibilityjs'; +import Poll from '../../lib/utils/poll'; +import * as types from './mutation_types'; +import * as utils from './utils'; +import * as constants from '../constants'; +import service from '../services/issue_notes_service'; +import loadAwardsHandler from '../../awards_handler'; +import sidebarTimeTrackingEventHub from '../../sidebar/event_hub'; + +let eTagPoll; + +export const setNotesData = ({ commit }, data) => commit(types.SET_NOTES_DATA, data); +export const setIssueData = ({ commit }, data) => commit(types.SET_ISSUE_DATA, data); +export const setUserData = ({ commit }, data) => commit(types.SET_USER_DATA, data); +export const setLastFetchedAt = ({ commit }, data) => commit(types.SET_LAST_FETCHED_AT, data); +export const setInitialNotes = ({ commit }, data) => commit(types.SET_INITIAL_NOTES, data); +export const setTargetNoteHash = ({ commit }, data) => commit(types.SET_TARGET_NOTE_HASH, data); +export const toggleDiscussion = ({ commit }, data) => commit(types.TOGGLE_DISCUSSION, data); + +export const fetchNotes = ({ commit }, path) => service + .fetchNotes(path) + .then(res => res.json()) + .then((res) => { + commit(types.SET_INITIAL_NOTES, res); + }); + +export const deleteNote = ({ commit }, note) => service + .deleteNote(note.path) + .then(() => { + commit(types.DELETE_NOTE, note); + }); + +export const updateNote = ({ commit }, { endpoint, note }) => service + .updateNote(endpoint, note) + .then(res => res.json()) + .then((res) => { + commit(types.UPDATE_NOTE, res); + }); + +export const replyToDiscussion = ({ commit }, { endpoint, data }) => service + .replyToDiscussion(endpoint, data) + .then(res => res.json()) + .then((res) => { + commit(types.ADD_NEW_REPLY_TO_DISCUSSION, res); + + return res; + }); + +export const createNewNote = ({ commit }, { endpoint, data }) => service + .createNewNote(endpoint, data) + .then(res => res.json()) + .then((res) => { + if (!res.errors) { + commit(types.ADD_NEW_NOTE, res); + } + return res; + }); + +export const removePlaceholderNotes = ({ commit }) => + commit(types.REMOVE_PLACEHOLDER_NOTES); + +export const saveNote = ({ commit, dispatch }, noteData) => { + const { note } = noteData.data.note; + let placeholderText = note; + const hasQuickActions = utils.hasQuickActions(placeholderText); + const replyId = noteData.data.in_reply_to_discussion_id; + const methodToDispatch = replyId ? 'replyToDiscussion' : 'createNewNote'; + + commit(types.REMOVE_PLACEHOLDER_NOTES); // remove previous placeholders + $('.notes-form .flash-container').hide(); // hide previous flash notification + + if (hasQuickActions) { + placeholderText = utils.stripQuickActions(placeholderText); + } + + if (placeholderText.length) { + commit(types.SHOW_PLACEHOLDER_NOTE, { + noteBody: placeholderText, + replyId, + }); + } + + if (hasQuickActions) { + commit(types.SHOW_PLACEHOLDER_NOTE, { + isSystemNote: true, + noteBody: utils.getQuickActionText(note), + replyId, + }); + } + + return dispatch(methodToDispatch, noteData) + .then((res) => { + const { errors } = res; + const commandsChanges = res.commands_changes; + + if (hasQuickActions && errors && Object.keys(errors).length) { + eTagPoll.makeRequest(); + + $('.js-gfm-input').trigger('clear-commands-cache.atwho'); + Flash('Commands applied', 'notice', $(noteData.flashContainer)); + } + + if (commandsChanges) { + if (commandsChanges.emoji_award) { + const votesBlock = $('.js-awards-block').eq(0); + + loadAwardsHandler() + .then((awardsHandler) => { + awardsHandler.addAwardToEmojiBar(votesBlock, commandsChanges.emoji_award); + awardsHandler.scrollToAwards(); + }) + .catch(() => { + Flash( + 'Something went wrong while adding your award. Please try again.', + null, + $(noteData.flashContainer), + ); + }); + } + + if (commandsChanges.spend_time != null || commandsChanges.time_estimate != null) { + sidebarTimeTrackingEventHub.$emit('timeTrackingUpdated', res); + } + } + + if (errors && errors.commands_only) { + Flash(errors.commands_only, 'notice', $(noteData.flashContainer)); + } + commit(types.REMOVE_PLACEHOLDER_NOTES); + + return res; + }); +}; + +const pollSuccessCallBack = (resp, commit, state, getters) => { + if (resp.notes && resp.notes.length) { + const { notesById } = getters; + + resp.notes.forEach((note) => { + if (notesById[note.id]) { + commit(types.UPDATE_NOTE, note); + } else if (note.type === constants.DISCUSSION_NOTE) { + const discussion = utils.findNoteObjectById(state.notes, note.discussion_id); + + if (discussion) { + commit(types.ADD_NEW_REPLY_TO_DISCUSSION, note); + } else { + commit(types.ADD_NEW_NOTE, note); + } + } else { + commit(types.ADD_NEW_NOTE, note); + } + }); + } + + commit(types.SET_LAST_FETCHED_AT, resp.lastFetchedAt); + + return resp; +}; + +export const poll = ({ commit, state, getters }) => { + const requestData = { endpoint: state.notesData.notesPath, lastFetchedAt: state.lastFetchedAt }; + + eTagPoll = new Poll({ + resource: service, + method: 'poll', + data: requestData, + successCallback: resp => resp.json() + .then(data => pollSuccessCallBack(data, commit, state, getters)), + errorCallback: () => Flash('Something went wrong while fetching latest comments.'), + }); + + if (!Visibility.hidden()) { + eTagPoll.makeRequest(); + } else { + service.poll(requestData); + } + + Visibility.change(() => { + if (!Visibility.hidden()) { + eTagPoll.restart(); + } else { + eTagPoll.stop(); + } + }); +}; + +export const fetchData = ({ commit, state, getters }) => { + const requestData = { endpoint: state.notesData.notesPath, lastFetchedAt: state.lastFetchedAt }; + + service.poll(requestData) + .then(resp => resp.json) + .then(data => pollSuccessCallBack(data, commit, state, getters)) + .catch(() => Flash('Something went wrong while fetching latest comments.')); +}; + +export const toggleAward = ({ commit, state, getters, dispatch }, { awardName, noteId }) => { + commit(types.TOGGLE_AWARD, { awardName, note: getters.notesById[noteId] }); +}; + +export const toggleAwardRequest = ({ commit, getters, dispatch }, data) => { + const { endpoint, awardName } = data; + + return service + .toggleAward(endpoint, { name: awardName }) + .then(res => res.json()) + .then(() => { + dispatch('toggleAward', data); + }); +}; + +export const scrollToNoteIfNeeded = (context, el) => { + if (!gl.utils.isInViewport(el[0])) { + gl.utils.scrollToElement(el); + } +}; diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js new file mode 100644 index 00000000000..1f0c6af6156 --- /dev/null +++ b/app/assets/javascripts/notes/stores/getters.js @@ -0,0 +1,31 @@ +import _ from 'underscore'; + +export const notes = state => state.notes; +export const targetNoteHash = state => state.targetNoteHash; + +export const getNotesData = state => state.notesData; +export const getNotesDataByProp = state => prop => state.notesData[prop]; + +export const getIssueData = state => state.issueData; +export const getIssueDataByProp = state => prop => state.issueData[prop]; + +export const getUserData = state => state.userData || {}; +export const getUserDataByProp = state => prop => state.userData && state.userData[prop]; + +export const notesById = state => state.notes.reduce((acc, note) => { + note.notes.every(n => Object.assign(acc, { [n.id]: n })); + return acc; +}, {}); + +const reverseNotes = array => array.slice(0).reverse(); +const isLastNote = (note, state) => !note.system && + state.userData && note.author && + note.author.id === state.userData.id; + +export const getCurrentUserLastNote = state => _.flatten( + reverseNotes(state.notes) + .map(note => reverseNotes(note.notes)), + ).find(el => isLastNote(el, state)); + +export const getDiscussionLastNote = state => discussion => reverseNotes(discussion.notes) + .find(el => isLastNote(el, state)); diff --git a/app/assets/javascripts/notes/stores/index.js b/app/assets/javascripts/notes/stores/index.js new file mode 100644 index 00000000000..8e0c8531bbc --- /dev/null +++ b/app/assets/javascripts/notes/stores/index.js @@ -0,0 +1,23 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import * as actions from './actions'; +import * as getters from './getters'; +import mutations from './mutations'; + +Vue.use(Vuex); + +export default new Vuex.Store({ + state: { + notes: [], + targetNoteHash: null, + lastFetchedAt: null, + + // holds endpoints and permissions provided through haml + notesData: {}, + userData: {}, + issueData: {}, + }, + actions, + getters, + mutations, +}); diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js new file mode 100644 index 00000000000..cd71533ba9d --- /dev/null +++ b/app/assets/javascripts/notes/stores/mutation_types.js @@ -0,0 +1,14 @@ +export const ADD_NEW_NOTE = 'ADD_NEW_NOTE'; +export const ADD_NEW_REPLY_TO_DISCUSSION = 'ADD_NEW_REPLY_TO_DISCUSSION'; +export const DELETE_NOTE = 'DELETE_NOTE'; +export const REMOVE_PLACEHOLDER_NOTES = 'REMOVE_PLACEHOLDER_NOTES'; +export const SET_NOTES_DATA = 'SET_NOTES_DATA'; +export const SET_ISSUE_DATA = 'SET_ISSUE_DATA'; +export const SET_USER_DATA = 'SET_USER_DATA'; +export const SET_INITIAL_NOTES = 'SET_INITIAL_NOTES'; +export const SET_LAST_FETCHED_AT = 'SET_LAST_FETCHED_AT'; +export const SET_TARGET_NOTE_HASH = 'SET_TARGET_NOTE_HASH'; +export const SHOW_PLACEHOLDER_NOTE = 'SHOW_PLACEHOLDER_NOTE'; +export const TOGGLE_AWARD = 'TOGGLE_AWARD'; +export const TOGGLE_DISCUSSION = 'TOGGLE_DISCUSSION'; +export const UPDATE_NOTE = 'UPDATE_NOTE'; diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js new file mode 100644 index 00000000000..3b2b2089d6e --- /dev/null +++ b/app/assets/javascripts/notes/stores/mutations.js @@ -0,0 +1,151 @@ +import * as utils from './utils'; +import * as types from './mutation_types'; +import * as constants from '../constants'; + +export default { + [types.ADD_NEW_NOTE](state, note) { + const { discussion_id, type } = note; + const noteData = { + expanded: true, + id: discussion_id, + individual_note: !(type === constants.DISCUSSION_NOTE), + notes: [note], + reply_id: discussion_id, + }; + + state.notes.push(noteData); + }, + + [types.ADD_NEW_REPLY_TO_DISCUSSION](state, note) { + const noteObj = utils.findNoteObjectById(state.notes, note.discussion_id); + + if (noteObj) { + noteObj.notes.push(note); + } + }, + + [types.DELETE_NOTE](state, note) { + const noteObj = utils.findNoteObjectById(state.notes, note.discussion_id); + + if (noteObj.individual_note) { + state.notes.splice(state.notes.indexOf(noteObj), 1); + } else { + const comment = utils.findNoteObjectById(noteObj.notes, note.id); + noteObj.notes.splice(noteObj.notes.indexOf(comment), 1); + + if (!noteObj.notes.length) { + state.notes.splice(state.notes.indexOf(noteObj), 1); + } + } + }, + + [types.REMOVE_PLACEHOLDER_NOTES](state) { + const { notes } = state; + + for (let i = notes.length - 1; i >= 0; i -= 1) { + const note = notes[i]; + const children = note.notes; + + if (children.length && !note.individual_note) { // remove placeholder from discussions + for (let j = children.length - 1; j >= 0; j -= 1) { + if (children[j].isPlaceholderNote) { + children.splice(j, 1); + } + } + } else if (note.isPlaceholderNote) { // remove placeholders from state root + notes.splice(i, 1); + } + } + }, + + [types.SET_NOTES_DATA](state, data) { + Object.assign(state, { notesData: data }); + }, + + [types.SET_ISSUE_DATA](state, data) { + Object.assign(state, { issueData: data }); + }, + + [types.SET_USER_DATA](state, data) { + Object.assign(state, { userData: data }); + }, + [types.SET_INITIAL_NOTES](state, notesData) { + const notes = []; + + notesData.forEach((note) => { + // To support legacy notes, should be very rare case. + if (note.individual_note && note.notes.length > 1) { + note.notes.forEach((n) => { + const nn = Object.assign({}, note); + nn.notes = [n]; // override notes array to only have one item to mimick individual_note + notes.push(nn); + }); + } else { + notes.push(note); + } + }); + + Object.assign(state, { notes }); + }, + + [types.SET_LAST_FETCHED_AT](state, fetchedAt) { + Object.assign(state, { lastFetchedAt: fetchedAt }); + }, + + [types.SET_TARGET_NOTE_HASH](state, hash) { + Object.assign(state, { targetNoteHash: hash }); + }, + + [types.SHOW_PLACEHOLDER_NOTE](state, data) { + let notesArr = state.notes; + if (data.replyId) { + notesArr = utils.findNoteObjectById(notesArr, data.replyId).notes; + } + + notesArr.push({ + individual_note: true, + isPlaceholderNote: true, + placeholderType: data.isSystemNote ? constants.SYSTEM_NOTE : constants.NOTE, + notes: [ + { + body: data.noteBody, + }, + ], + }); + }, + + [types.TOGGLE_AWARD](state, data) { + const { awardName, note } = data; + const { id, name, username } = state.userData; + + const hasEmojiAwardedByCurrentUser = note.award_emoji + .filter(emoji => emoji.name === data.awardName && emoji.user.id === id); + + if (hasEmojiAwardedByCurrentUser.length) { + // If current user has awarded this emoji, remove it. + note.award_emoji.splice(note.award_emoji.indexOf(hasEmojiAwardedByCurrentUser[0]), 1); + } else { + note.award_emoji.push({ + name: awardName, + user: { id, name, username }, + }); + } + }, + + [types.TOGGLE_DISCUSSION](state, { discussionId }) { + const discussion = utils.findNoteObjectById(state.notes, discussionId); + + discussion.expanded = !discussion.expanded; + }, + + [types.UPDATE_NOTE](state, note) { + const noteObj = utils.findNoteObjectById(state.notes, note.discussion_id); + + if (noteObj.individual_note) { + noteObj.notes.splice(0, 1, note); + } else { + const comment = utils.findNoteObjectById(noteObj.notes, note.id); + noteObj.notes.splice(noteObj.notes.indexOf(comment), 1, note); + } + }, +}; diff --git a/app/assets/javascripts/notes/stores/utils.js b/app/assets/javascripts/notes/stores/utils.js new file mode 100644 index 00000000000..6074115e855 --- /dev/null +++ b/app/assets/javascripts/notes/stores/utils.js @@ -0,0 +1,31 @@ +import AjaxCache from '~/lib/utils/ajax_cache'; + +const REGEX_QUICK_ACTIONS = /^\/\w+.*$/gm; + +export const findNoteObjectById = (notes, id) => notes.filter(n => n.id === id)[0]; + +export const getQuickActionText = (note) => { + let text = 'Applying command'; + const quickActions = AjaxCache.get(gl.GfmAutoComplete.dataSources.commands) || []; + + const executedCommands = quickActions.filter((command) => { + const commandRegex = new RegExp(`/${command.name}`); + return commandRegex.test(note); + }); + + if (executedCommands && executedCommands.length) { + if (executedCommands.length > 1) { + text = 'Applying multiple commands'; + } else { + const commandDescription = executedCommands[0].description.toLowerCase(); + text = `Applying command to ${commandDescription}`; + } + } + + return text; +}; + +export const hasQuickActions = note => REGEX_QUICK_ACTIONS.test(note); + +export const stripQuickActions = note => note.replace(REGEX_QUICK_ACTIONS, '').trim(); + diff --git a/app/assets/javascripts/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 @@ }; </script> <template> - <div> + <div class="ci-job-dropdown-container"> <button v-tooltip type="button" diff --git a/app/assets/javascripts/pipelines/components/graph/job_component.vue b/app/assets/javascripts/pipelines/components/graph/job_component.vue index 1f5ed3f1074..3933509a6f4 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_component.vue @@ -75,7 +75,7 @@ }; </script> <template> - <div> + <div class="ci-job-component"> <a v-tooltip v-if="job.status.details_path" diff --git a/app/assets/javascripts/pipelines/components/graph/job_name_component.vue b/app/assets/javascripts/pipelines/components/graph/job_name_component.vue index d8856e10668..f46d21bd6d7 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_name_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_name_component.vue @@ -26,7 +26,7 @@ }; </script> <template> - <span> + <span class="ci-job-name-component"> <ci-icon :status="status" /> diff --git a/app/assets/javascripts/shortcuts_issuable.js b/app/assets/javascripts/shortcuts_issuable.js index 0be141eb5f9..78b257bf192 100644 --- a/app/assets/javascripts/shortcuts_issuable.js +++ b/app/assets/javascripts/shortcuts_issuable.js @@ -20,7 +20,7 @@ import './shortcuts_navigation'; Mousetrap.bind('m', this.openSidebarDropdown.bind(this, 'milestone')); Mousetrap.bind('r', (function(_this) { return function() { - _this.replyWithSelectedText(); + _this.replyWithSelectedText(isMergeRequest); return false; }; })(this)); @@ -38,9 +38,15 @@ import './shortcuts_navigation'; } } - ShortcutsIssuable.prototype.replyWithSelectedText = function() { + ShortcutsIssuable.prototype.replyWithSelectedText = function(isMergeRequest) { var quote, documentFragment, el, selected, separator; - var replyField = $('.js-main-target-form #note_note'); + let replyField; + + if (isMergeRequest) { + replyField = $('.js-main-target-form #note_note'); + } else { + replyField = $('.js-main-target-form .js-vue-comment-form'); + } documentFragment = window.gl.utils.getSelectedFragment(); if (!documentFragment) { @@ -57,6 +63,7 @@ import './shortcuts_navigation'; quote = _.map(selected.split("\n"), function(val) { return ("> " + val).trim() + "\n"; }); + // If replyField already has some content, add a newline before our quote separator = replyField.val().trim() !== "" && "\n\n" || ''; replyField.val(function(a, current) { @@ -64,7 +71,7 @@ import './shortcuts_navigation'; }); // Trigger autosave - replyField.trigger('input'); + replyField.trigger('input').trigger('change'); // Trigger autosize var event = document.createEvent('Event'); diff --git a/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js index 2d682215cf8..d32fe4abc7d 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js +++ b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js @@ -6,6 +6,7 @@ import timeTracker from './time_tracker'; import Store from '../../stores/sidebar_store'; import Mediator from '../../sidebar_mediator'; +import eventHub from '../../event_hub'; export default { data() { @@ -20,6 +21,9 @@ export default { methods: { listenForQuickActions() { $(document).on('ajax:success', '.gfm-form', this.quickActionListened); + eventHub.$on('timeTrackingUpdated', (data) => { + this.quickActionListened(null, data); + }); }, quickActionListened(e, data) { const subscribedCommands = ['spend_time', 'time_estimate']; diff --git a/app/assets/javascripts/vue_shared/components/issue/confidential_issue_warning.vue b/app/assets/javascripts/vue_shared/components/issue/confidential_issue_warning.vue new file mode 100644 index 00000000000..397d16331d5 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/issue/confidential_issue_warning.vue @@ -0,0 +1,16 @@ +<script> + export default { + name: 'confidentialIssueWarning', + }; +</script> +<template> + <div class="confidential-issue-warning"> + <i + aria-hidden="true" + class="fa fa-eye-slash"> + </i> + <span> + This is a confidential issue. Your comment will not be visible to the public. + </span> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index 4e10bbc7408..759d30c9c7c 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -5,19 +5,30 @@ export default { props: { - markdownPreviewUrl: { + markdownPreviewPath: { type: String, required: false, default: '', }, - markdownDocs: { + markdownDocsPath: { type: String, required: true, }, + addSpacingClasses: { + type: Boolean, + required: false, + default: true, + }, + quickActionsDocsPath: { + type: String, + required: false, + }, }, data() { return { markdownPreview: '', + referencedCommands: '', + referencedUsers: '', markdownPreviewLoading: false, previewMarkdown: false, }; @@ -26,35 +37,48 @@ markdownHeader, markdownToolbar, }, + computed: { + shouldShowReferencedUsers() { + const referencedUsersThreshold = 10; + return this.referencedUsers.length >= referencedUsersThreshold; + }, + }, methods: { toggleMarkdownPreview() { this.previewMarkdown = !this.previewMarkdown; + /* + Can't use `$refs` as the component is technically in the parent component + so we access the VNode & then get the element + */ + const text = this.$slots.textarea[0].elm.value; + if (!this.previewMarkdown) { this.markdownPreview = ''; - } else { + } else if (text) { this.markdownPreviewLoading = true; - this.$http.post( - this.markdownPreviewUrl, - { - /* - Can't use `$refs` as the component is technically in the parent component - so we access the VNode & then get the element - */ - text: this.$slots.textarea[0].elm.value, - }, - ) - .then(resp => resp.json()) - .then((data) => { - this.markdownPreviewLoading = false; - this.markdownPreview = data.body; + this.$http.post(this.markdownPreviewPath, { text }) + .then(resp => resp.json()) + .then((data) => { + this.renderMarkdown(data); + }) + .catch(() => new Flash('Error loading markdown preview')); + } else { + this.renderMarkdown(); + } + }, + renderMarkdown(data = {}) { + this.markdownPreviewLoading = false; + this.markdownPreview = data.body || 'Nothing to preview.'; - this.$nextTick(() => { - $(this.$refs['markdown-preview']).renderGFM(); - }); - }) - .catch(() => new Flash('Error loading markdown preview')); + if (data.references) { + this.referencedCommands = data.references.commands; + this.referencedUsers = data.references.users; } + + this.$nextTick(() => { + $(this.$refs['markdown-preview']).renderGFM(); + }); }, }, mounted() { @@ -74,7 +98,8 @@ <template> <div - class="md-area prepend-top-default append-bottom-default js-vue-markdown-field" + class="md-area js-vue-markdown-field" + :class="{ 'prepend-top-default append-bottom-default': addSpacingClasses }" ref="gl-form"> <markdown-header :preview-markdown="previewMarkdown" @@ -94,7 +119,9 @@ </i> </a> <markdown-toolbar - :markdown-docs="markdownDocs" /> + :markdown-docs-path="markdownDocsPath" + :quick-actions-docs-path="quickActionsDocsPath" + /> </div> </div> <div @@ -108,5 +135,27 @@ Loading... </span> </div> + <template v-if="previewMarkdown && !markdownPreviewLoading"> + <div + v-if="referencedCommands" + v-html="referencedCommands" + class="referenced-commands"></div> + <div + v-if="shouldShowReferencedUsers" + class="referenced-users"> + <span> + <i + class="fa fa-exclamation-triangle" + aria-hidden="true"> + </i> + You are about to add + <strong> + <span class="js-referenced-users-count"> + {{referencedUsers.length}} + </span> + </strong> people to the discussion. Proceed with caution. + </span> + </div> + </template> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue index 93252293ba6..65fe7bbd94e 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue @@ -1,10 +1,14 @@ <script> export default { props: { - markdownDocs: { + markdownDocsPath: { type: String, required: true, }, + quickActionsDocsPath: { + type: String, + required: false, + }, }, }; </script> @@ -12,22 +16,77 @@ <template> <div class="comment-toolbar clearfix"> <div class="toolbar-text"> - <a - :href="markdownDocs" - target="_blank" - tabindex="-1"> - Markdown is supported - </a> + <template v-if="!quickActionsDocsPath && markdownDocsPath"> + <a + :href="markdownDocsPath" + target="_blank" + tabindex="-1"> + Markdown is supported + </a> + </template> + <template v-if="quickActionsDocsPath && markdownDocsPath"> + <a + :href="markdownDocsPath" + target="_blank" + tabindex="-1"> + Markdown + </a> + and + <a + :href="quickActionsDocsPath" + target="_blank" + tabindex="-1"> + quick actions + </a> + are supported + </template> </div> - <button - class="toolbar-button markdown-selector" - type="button" - tabindex="-1"> - <i - class="fa fa-file-image-o toolbar-button-icon" - aria-hidden="true"> - </i> - Attach a file - </button> + <span class="uploading-container"> + <span class="uploading-progress-container hide"> + <i + class="fa fa-file-image-o toolbar-button-icon" + aria-hidden="true"></i> + <span class="attaching-file-message"></span> + <span class="uploading-progress">0%</span> + <span class="uploading-spinner"> + <i + class="fa fa-spinner fa-spin toolbar-button-icon" + aria-hidden="true"></i> + </span> + </span> + <span class="uploading-error-container hide"> + <span class="uploading-error-icon"> + <i + class="fa fa-file-image-o toolbar-button-icon" + aria-hidden="true"></i> + </span> + <span class="uploading-error-message"></span> + <button + class="retry-uploading-link" + type="button"> + Try again + </button> + or + <button + class="attach-new-file markdown-selector" + type="button"> + attach a new file + </button> + </span> + <button + class="markdown-selector button-attach-file" + tabindex="-1" + type="button"> + <i + class="fa fa-file-image-o toolbar-button-icon" + aria-hidden="true"></i> + Attach a file + </button> + <button + class="btn btn-default btn-xs hide button-cancel-uploading-files" + type="button"> + Cancel + </button> + </span> </div> </template> diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index e16fbbf43b5..68a51c5a461 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -16,6 +16,7 @@ .prepend-left-default { margin-left: $gl-padding; } .prepend-left-20 { margin-left: 20px; } .append-right-5 { margin-right: 5px; } +.append-right-8 { margin-right: 8px; } .append-right-10 { margin-right: 10px; } .append-right-default { margin-right: $gl-padding; } .append-right-20 { margin-right: 20px; } diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index 5871383a57b..1c40c7155c1 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 { @@ -729,6 +733,7 @@ #{$selector}.dropdown-menu, #{$selector}.dropdown-menu-nav { li { + display: block; padding: 0 1px; &:hover { @@ -748,9 +753,12 @@ } a, - button { + button, + .menu-item { border-radius: 0; padding: 8px 16px; + text-align: left; + width: 100%; // make sure the text color is not overriden &.text-danger { diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 26920869bec..01fffa717e9 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -591,9 +591,10 @@ $ui-dev-kit-example-border: #ddd; /* Pipeline Graph */ -$stage-hover-bg: #eaf3fc; -$stage-hover-border: #d1e7fc; -$action-icon-color: #d6d6d6; +$stage-hover-bg: $gray-darker; +$ci-action-icon-size: 22px; +$pipeline-dropdown-line-height: 20px; +$pipeline-dropdown-status-icon-size: 18px; /* Pipeline Schedules diff --git a/app/assets/stylesheets/new_nav.scss b/app/assets/stylesheets/new_nav.scss index 54fa4109f8b..b711bd12c73 100644 --- a/app/assets/stylesheets/new_nav.scss +++ b/app/assets/stylesheets/new_nav.scss @@ -8,15 +8,23 @@ header.navbar-gitlab-new { border-bottom: 0; .header-content { + display: -webkit-flex; + display: flex; padding-left: 0; .title-container { + display: -webkit-flex; + display: flex; + -webkit-align-items: stretch; align-items: stretch; + -webkit-flex: 1 1 auto; + flex: 1 1 auto; padding-top: 0; overflow: visible; } .title { + display: -webkit-flex; display: flex; padding-right: 0; color: currentColor; @@ -27,6 +35,7 @@ header.navbar-gitlab-new { } > a { + display: -webkit-flex; display: flex; align-items: center; padding-right: $gl-padding; @@ -177,6 +186,7 @@ header.navbar-gitlab-new { } .navbar-sub-nav { + display: -webkit-flex; display: flex; margin-bottom: 0; color: $indigo-200; 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/members.scss b/app/assets/stylesheets/pages/members.scss index 3fb02e9964f..b3bab082a35 100644 --- a/app/assets/stylesheets/pages/members.scss +++ b/app/assets/stylesheets/pages/members.scss @@ -55,6 +55,10 @@ display: -webkit-flex; display: flex; } + + .dropdown-menu.dropdown-menu-align-right { + margin-top: -2px; + } } .form-horizontal { @@ -306,3 +310,7 @@ } } } + +.member-form-control { + @include new-style-dropdown; +} diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 9d51c0b7a8a..d7f53a74825 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -174,17 +174,6 @@ vertical-align: top; } - .mini-pipeline-graph-dropdown-menu .mini-pipeline-graph-dropdown-item { - display: flex; - align-items: center; - - .ci-status-text, - .ci-status-icon { - top: 0; - margin-right: 10px; - } - } - .normal { line-height: 28px; } @@ -291,6 +280,7 @@ .dropdown-toggle { .fa { + margin-left: 0; color: inherit; } } 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..45f2aed1531 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, @@ -365,9 +379,7 @@ ul.notes { } .discussion-header, -.note-header { - position: relative; - +.note-header-info { a { color: inherit; @@ -402,6 +414,10 @@ ul.notes { .note-header-info { min-width: 0; padding-bottom: 8px; + + &.discussion { + padding-bottom: 0; + } } .system-note .note-header-info { @@ -453,6 +469,8 @@ ul.notes { } .note-actions { + @include new-style-dropdown; + align-self: flex-start; flex-shrink: 0; display: inline-flex; @@ -488,22 +506,6 @@ ul.notes { .more-actions-dropdown { width: 180px; min-width: 180px; - margin-top: $gl-btn-padding; - - li > a, - li > .btn { - color: $gl-text-color; - padding: $gl-btn-padding; - width: 100%; - text-align: left; - - &:hover, - &:focus { - color: $gl-text-color; - background-color: $blue-25; - border-radius: $border-radius-default; - } - } } .discussion-actions { @@ -814,10 +816,6 @@ ul.notes { } } -.discussion-notes .flash-container { - margin-bottom: 0; -} - // Merge request notes in diffs .diff-file { // Diff is inline diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index 9071e1ca0c8..883f7b392c0 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -40,7 +40,7 @@ .btn.btn-retry:hover, .btn.btn-retry:focus { - border-color: $gray-darkest; + border-color: $dropdown-toggle-active-border-color; background-color: $white-normal; } @@ -206,8 +206,8 @@ .stage-cell { .mini-pipeline-graph-dropdown-toggle svg { - height: 22px; - width: 22px; + height: $ci-action-icon-size; + width: $ci-action-icon-size; position: absolute; top: -1px; left: -1px; @@ -219,7 +219,7 @@ display: inline-block; position: relative; vertical-align: middle; - height: 22px; + height: $ci-action-icon-size; margin: 3px 0; + .stage-container { @@ -308,7 +308,7 @@ a { text-decoration: none; - color: $gl-text-color-secondary; + color: $gl-text-color; } svg { @@ -432,7 +432,11 @@ width: 186px; margin-bottom: 10px; white-space: normal; - color: $gl-text-color-secondary; + + // ensure .build-content has hover style when action-icon is hovered + .ci-job-dropdown-container:hover .build-content { + @extend .build-content:hover; + } // Action Icons in big pipeline-graph nodes .ci-action-icon-container .ci-action-icon-wrapper { @@ -445,11 +449,11 @@ &:hover { background-color: $stage-hover-bg; - border: 1px solid $stage-hover-bg; + border: 1px solid $dropdown-toggle-active-border-color; } svg { - fill: $border-color; + fill: $gl-text-color-secondary; position: relative; left: -1px; top: -1px; @@ -475,19 +479,10 @@ background-color: transparent; border: none; padding: 0; - color: $gl-text-color-secondary; &:focus { outline: none; } - - &:hover { - color: $gl-text-color; - - .dropdown-counter-badge { - color: $gl-text-color; - } - } } .build-content { @@ -502,8 +497,7 @@ a.build-content:hover, button.build-content:hover { background-color: $stage-hover-bg; - border: 1px solid $stage-hover-border; - color: $gl-text-color; + border: 1px solid $dropdown-toggle-active-border-color; } @@ -564,7 +558,6 @@ // Triggers the dropdown in the big pipeline graph .dropdown-counter-badge { - color: $border-color; font-weight: 100; font-size: 15px; position: absolute; @@ -606,8 +599,8 @@ button.mini-pipeline-graph-dropdown-toggle { background-color: $white-light; border-width: 1px; border-style: solid; - width: 22px; - height: 22px; + width: $ci-action-icon-size; + height: $ci-action-icon-size; margin: 0; padding: 0; transition: all 0.2s linear; @@ -669,105 +662,119 @@ button.mini-pipeline-graph-dropdown-toggle { } } +@include new-style-dropdown('.big-pipeline-graph-dropdown-menu'); +@include new-style-dropdown('.mini-pipeline-graph-dropdown-menu'); + // dropdown content for big and mini pipeline .big-pipeline-graph-dropdown-menu, .mini-pipeline-graph-dropdown-menu { width: 195px; max-width: 195px; - li { - padding: 2px 3px; - } - .scrollable-menu { padding: 0; max-height: 245px; overflow: auto; } - // Action icon on the right - a.ci-action-icon-wrapper { - color: $action-icon-color; - border: 1px solid $action-icon-color; - border-radius: 20px; - width: 22px; - height: 22px; - padding: 2px 0 0 5px; - cursor: pointer; - float: right; - margin: -26px 9px 0 0; - font-size: 12px; - background-color: $white-light; + li { + position: relative; - &:hover, - &:focus { - background-color: $stage-hover-bg; - border: 1px solid transparent; + // ensure .mini-pipeline-graph-dropdown-item has hover style when action-icon is hovered + &:hover > .mini-pipeline-graph-dropdown-item, + &:hover > .ci-job-component > .mini-pipeline-graph-dropdown-item { + @extend .mini-pipeline-graph-dropdown-item:hover; } - svg { - width: 22px; - height: 22px; - left: -6px; - position: relative; - top: -3px; - fill: $action-icon-color; - } + // Action icon on the right + a.ci-action-icon-wrapper { + border-radius: 50%; + border: 1px solid $border-color; + width: $ci-action-icon-size; + height: $ci-action-icon-size; + padding: 2px 0 0 5px; + font-size: 12px; + background-color: $white-light; + position: absolute; + top: 50%; + right: $gl-padding; + margin-top: -#{$ci-action-icon-size / 2}; - &:hover svg, - &:focus svg { - fill: $gl-text-color; - } - } + &:hover, + &:focus { + background-color: $stage-hover-bg; + border: 1px solid $dropdown-toggle-active-border-color; + } - // link to the build - .mini-pipeline-graph-dropdown-item { - padding: 3px 7px 4px; - clear: both; - font-weight: $gl-font-weight-normal; - line-height: 1.428571429; - white-space: nowrap; - margin: 0 5px; - border-radius: 3px; + svg { + fill: $gl-text-color-secondary; + width: $ci-action-icon-size; + height: $ci-action-icon-size; + left: -6px; + position: relative; + top: -3px; + } - // build name - .ci-build-text, - .ci-status-text { - font-weight: 200; - overflow: hidden; + &:hover svg, + &:focus svg { + fill: $gl-text-color; + } + } + + // link to the build + .mini-pipeline-graph-dropdown-item { + padding: 3px 7px 4px; + align-items: center; + clear: both; + display: flex; + font-weight: normal; + line-height: $line-height-base; white-space: nowrap; - text-overflow: ellipsis; - max-width: 70%; - color: $gl-text-color-secondary; - margin-left: 2px; - display: inline-block; - top: 1px; - vertical-align: text-bottom; - position: relative; + border-radius: 3px; - @media (max-width: $screen-xs-max) { - max-width: 60%; + .ci-job-name-component { + align-items: center; + display: flex; + flex: 1; } - } - // status icon on the left - .ci-status-icon { - top: 3px; - position: relative; + // build name + .ci-build-text, + .ci-status-text { + font-weight: 200; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + max-width: 70%; + margin-left: 2px; + display: inline-block; - > svg { - overflow: visible; - width: 18px; - height: 18px; + @media (max-width: $screen-xs-max) { + max-width: 60%; + } } - } - &:hover, - &:focus { - outline: none; - text-decoration: none; - color: $gl-text-color; - background-color: $stage-hover-bg; + .ci-status-icon { + @extend .append-right-8; + + position: relative; + + > svg { + width: $pipeline-dropdown-status-icon-size; + height: $pipeline-dropdown-status-icon-size; + margin: 3px 0; + position: relative; + overflow: visible; + display: block; + } + } + + &:hover, + &:focus { + outline: none; + text-decoration: none; + background-color: $stage-hover-bg; + } } } } @@ -776,16 +783,9 @@ button.mini-pipeline-graph-dropdown-toggle { .big-pipeline-graph-dropdown-menu { width: 195px; min-width: 195px; - left: auto; - right: -195px; - top: -4px; + left: 100%; + top: -10px; box-shadow: 0 1px 5px $black-transparent; - - .mini-pipeline-graph-dropdown-item { - .ci-status-icon { - top: -1px; - } - } } /** @@ -806,15 +806,14 @@ button.mini-pipeline-graph-dropdown-toggle { } &::before { - left: -5px; - margin-top: -6px; + left: -6px; + margin-top: 3px; border-width: 7px 5px 7px 0; border-right-color: $border-color; } &::after { - left: -4px; - margin-top: -9px; + left: -5px; border-width: 10px 7px 10px 0; border-right-color: $white-light; } diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 1d92ea11bda..97922e39ba8 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -202,7 +202,7 @@ class ApplicationController < ActionController::Base end def check_password_expiration - if current_user && current_user.password_expires_at && current_user.password_expires_at < Time.now && current_user.allow_password_authentication? + if current_user && current_user.password_expires_at && current_user.password_expires_at < Time.now && !current_user.ldap_user? return redirect_to new_profile_password_path end end 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/passwords_controller.rb b/app/controllers/passwords_controller.rb index aa8cf630032..fda944adecd 100644 --- a/app/controllers/passwords_controller.rb +++ b/app/controllers/passwords_controller.rb @@ -1,8 +1,6 @@ class PasswordsController < Devise::PasswordsController - include Gitlab::CurrentSettings - before_action :resource_from_email, only: [:create] - before_action :check_password_authentication_available, only: [:create] + before_action :prevent_ldap_reset, only: [:create] before_action :throttle_reset, only: [:create] def edit @@ -40,11 +38,11 @@ class PasswordsController < Devise::PasswordsController self.resource = resource_class.find_by_email(email) end - def check_password_authentication_available - return if current_application_settings.password_authentication_enabled? && (resource.nil? || resource.allow_password_authentication?) + def prevent_ldap_reset + return unless resource&.ldap_user? redirect_to after_sending_reset_password_instructions_path_for(resource_name), - alert: "Password authentication is unavailable." + alert: "Cannot reset password for LDAP user." end def throttle_reset diff --git a/app/controllers/profiles/passwords_controller.rb b/app/controllers/profiles/passwords_controller.rb index c423761ab24..7beb52dd8e8 100644 --- a/app/controllers/profiles/passwords_controller.rb +++ b/app/controllers/profiles/passwords_controller.rb @@ -77,7 +77,7 @@ class Profiles::PasswordsController < Profiles::ApplicationController end def authorize_change_password! - render_404 unless @user.allow_password_authentication? + render_404 if @user.ldap_user? end def user_params 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/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index 7e0d3b5c979..c8dd2275730 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -24,7 +24,6 @@ class IssuableFinder include CreatedAtFilter NONE = '0'.freeze - IRRELEVANT_PARAMS_FOR_CACHE_KEY = %i[utf8 sort page state].freeze attr_accessor :current_user, :params @@ -68,7 +67,7 @@ class IssuableFinder # grouping and counting within that query. # def count_by_state - count_params = params.merge(state: nil, sort: nil, for_counting: true) + count_params = params.merge(state: nil, sort: nil) labels_count = label_names.any? ? label_names.count : 1 finder = self.class.new(current_user, count_params) counts = Hash.new(0) @@ -91,16 +90,6 @@ class IssuableFinder execute.find_by!(*params) end - def state_counter_cache_key - cache_key(state_counter_cache_key_components) - end - - def clear_caches! - state_counter_cache_key_components_permutations.each do |components| - Rails.cache.delete(cache_key(components)) - end - end - def group return @group if defined?(@group) @@ -432,20 +421,4 @@ class IssuableFinder def current_user_related? params[:scope] == 'created-by-me' || params[:scope] == 'authored' || params[:scope] == 'assigned-to-me' end - - def state_counter_cache_key_components - opts = params.with_indifferent_access - opts.except!(*IRRELEVANT_PARAMS_FOR_CACHE_KEY) - opts.delete_if { |_, value| value.blank? } - - ['issuables_count', klass.to_ability_name, opts.sort] - end - - def state_counter_cache_key_components_permutations - [state_counter_cache_key_components] - end - - def cache_key(components) - Digest::SHA1.hexdigest(components.flatten.join('-')) - end end diff --git a/app/finders/issues_finder.rb b/app/finders/issues_finder.rb index 0ec42a4e6eb..aa9cef6b08c 100644 --- a/app/finders/issues_finder.rb +++ b/app/finders/issues_finder.rb @@ -54,44 +54,10 @@ class IssuesFinder < IssuableFinder project.team.max_member_access(current_user.id) >= CONFIDENTIAL_ACCESS_LEVEL end - # Anonymous users can't see any confidential issues. - # - # Users without access to see _all_ confidential issues (as in - # `user_can_see_all_confidential_issues?`) are more complicated, because they - # can see confidential issues where: - # 1. They are an assignee. - # 2. They are an author. - # - # That's fine for most cases, but if we're just counting, we need to cache - # effectively. If we cached this accurately, we'd have a cache key for every - # authenticated user without sufficient access to the project. Instead, when - # we are counting, we treat them as if they can't see any confidential issues. - # - # This does mean the counts may be wrong for those users, but avoids an - # explosion in cache keys. - def user_cannot_see_confidential_issues?(for_counting: false) + def user_cannot_see_confidential_issues? return false if user_can_see_all_confidential_issues? - current_user.blank? || for_counting || params[:for_counting] - end - - def state_counter_cache_key_components - extra_components = [ - user_can_see_all_confidential_issues?, - user_cannot_see_confidential_issues?(for_counting: true) - ] - - super + extra_components - end - - def state_counter_cache_key_components_permutations - # Ignore the last two, as we'll provide both options for them. - components = super.first[0..-3] - - [ - components + [false, true], - components + [true, false] - ] + current_user.blank? end def by_assignee(items) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 36bb7015fa1..017df8f6794 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -303,7 +303,7 @@ module ApplicationHelper end def show_new_nav? - cookies["new_nav"] == "true" + true end def collapsed_sidebar? diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index f97f7199648..7bd34df5c95 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -84,6 +84,18 @@ module ApplicationSettingsHelper end end + def key_restriction_options_for_select(type) + bit_size_options = Gitlab::SSHPublicKey.supported_sizes(type).map do |bits| + ["Must be at least #{bits} bits", bits] + end + + [ + ['Are allowed', 0], + *bit_size_options, + ['Are forbidden', ApplicationSetting::FORBIDDEN_KEY_VALUE] + ] + end + def repository_storages_options_for_select options = Gitlab.config.repositories.storages.map do |name, storage| ["#{name} - #{storage['path']}", name] @@ -117,6 +129,9 @@ module ApplicationSettingsHelper :domain_blacklist_enabled, :domain_blacklist_raw, :domain_whitelist_raw, + :dsa_key_restriction, + :ecdsa_key_restriction, + :ed25519_key_restriction, :email_author_in_body, :enabled_git_access_protocol, :gravatar_enabled, @@ -160,6 +175,7 @@ module ApplicationSettingsHelper :repository_storages, :require_two_factor_authentication, :restricted_visibility_levels, + :rsa_key_restriction, :send_user_confirmation_email, :sentry_dsn, :sentry_enabled, diff --git a/app/helpers/form_helper.rb b/app/helpers/form_helper.rb index 9247b1f72de..b5dece38de1 100644 --- a/app/helpers/form_helper.rb +++ b/app/helpers/form_helper.rb @@ -1,9 +1,9 @@ module FormHelper - def form_errors(model) + def form_errors(model, type: 'form') return unless model.errors.any? pluralized = 'error'.pluralize(model.errors.count) - headline = "The form contains the following #{pluralized}:" + headline = "The #{type} contains the following #{pluralized}:" content_tag(:div, class: 'alert alert-danger', id: 'error_explanation') do content_tag(:h4, headline) << diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index 4123a96911f..dd159d12aa0 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -68,7 +68,7 @@ module GroupsHelper def group_title_link(group, hidable: false) link_to(group_path(group), class: "group-path #{'hidable' if hidable}") do output = - if show_new_nav? + if show_new_nav? && !Rails.env.test? image_tag(group_icon(group), class: "avatar-tile", width: 16, height: 16) else "" diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 2a748ce0a75..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, @@ -240,16 +240,9 @@ module IssuablesHelper } end - def issuables_count_for_state(issuable_type, state, finder: nil) - finder ||= public_send("#{issuable_type}_finder") # rubocop:disable GitlabSecurity/PublicSend - cache_key = finder.state_counter_cache_key - - @counts ||= {} - @counts[cache_key] ||= Rails.cache.fetch(cache_key, expires_in: 2.minutes) do - finder.count_by_state - end - - @counts[cache_key][state] + def issuables_count_for_state(issuable_type, state) + finder = public_send("#{issuable_type}_finder") # rubocop:disable GitlabSecurity/PublicSend + finder.count_by_state[state] end def close_issuable_url(issuable) 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/projects_helper.rb b/app/helpers/projects_helper.rb index c5490a2d1a8..0bf94fd30db 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -62,7 +62,7 @@ module ProjectsHelper project_link = link_to project_path(project), { class: "project-item-select-holder" } do output = - if show_new_nav? + if show_new_nav? && !Rails.env.test? project_icon(project, alt: project.name, class: 'avatar-tile', width: 16, height: 16) else "" 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/application_setting.rb b/app/models/application_setting.rb index 8e446ff6dd8..3568e72e463 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -13,6 +13,11 @@ class ApplicationSetting < ActiveRecord::Base [\r\n] # any number of newline characters }x + # Setting a key restriction to `-1` means that all keys of this type are + # forbidden. + FORBIDDEN_KEY_VALUE = KeyRestrictionValidator::FORBIDDEN + SUPPORTED_KEY_TYPES = %i[rsa dsa ecdsa ed25519].freeze + serialize :restricted_visibility_levels # rubocop:disable Cop/ActiveRecordSerialize serialize :import_sources # rubocop:disable Cop/ActiveRecordSerialize serialize :disabled_oauth_sign_in_sources, Array # rubocop:disable Cop/ActiveRecordSerialize @@ -146,6 +151,12 @@ class ApplicationSetting < ActiveRecord::Base presence: true, numericality: { greater_than_or_equal_to: 0 } + SUPPORTED_KEY_TYPES.each do |type| + validates :"#{type}_key_restriction", presence: true, key_restriction: { type: type } + end + + validates :allowed_key_types, presence: true + validates_each :restricted_visibility_levels do |record, attr, value| value&.each do |level| unless Gitlab::VisibilityLevel.options.value?(level) @@ -171,6 +182,7 @@ class ApplicationSetting < ActiveRecord::Base end before_validation :ensure_uuid! + before_save :ensure_runners_registration_token before_save :ensure_health_check_access_token @@ -221,6 +233,9 @@ class ApplicationSetting < ActiveRecord::Base default_group_visibility: Settings.gitlab.default_projects_features['visibility_level'], disabled_oauth_sign_in_sources: [], domain_whitelist: Settings.gitlab['domain_whitelist'], + dsa_key_restriction: 0, + ecdsa_key_restriction: 0, + ed25519_key_restriction: 0, gravatar_enabled: Settings.gravatar['enabled'], help_page_text: nil, help_page_hide_commercial_content: false, @@ -239,6 +254,7 @@ class ApplicationSetting < ActiveRecord::Base max_attachment_size: Settings.gitlab['max_attachment_size'], password_authentication_enabled: Settings.gitlab['password_authentication_enabled'], performance_bar_allowed_group_id: nil, + rsa_key_restriction: 0, plantuml_enabled: false, plantuml_url: nil, project_export_enabled: true, @@ -413,6 +429,18 @@ class ApplicationSetting < ActiveRecord::Base usage_ping_can_be_configured? && super end + def allowed_key_types + SUPPORTED_KEY_TYPES.select do |type| + key_restriction_for(type) != FORBIDDEN_KEY_VALUE + end + end + + def key_restriction_for(type) + attr_name = "#{type}_key_restriction" + + has_attribute?(attr_name) ? public_send(attr_name) : FORBIDDEN_KEY_VALUE # rubocop:disable GitlabSecurity/PublicSend + end + private def ensure_uuid! 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/commit.rb b/app/models/commit.rb index d41c88b4e30..c943365016f 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -251,6 +251,28 @@ class Commit project.repository.next_branch("cherry-pick-#{short_id}", mild: true) end + def cherry_pick_description(user) + message_body = "(cherry picked from commit #{sha})" + + if merged_merge_request?(user) + commits_in_merge_request = merged_merge_request(user).commits + + if commits_in_merge_request.present? + message_body << "\n" + + commits_in_merge_request.reverse.each do |commit_in_merge| + message_body << "\n#{commit_in_merge.short_id} #{commit_in_merge.title}" + end + end + end + + message_body + end + + def cherry_pick_message(user) + %Q{#{message}\n\n#{cherry_pick_description(user)}} + end + def revert_description(user) if merged_merge_request?(user) "This reverts merge request #{merged_merge_request(user).to_reference}" 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/key.rb b/app/models/key.rb index 49bc26122fa..a6b4dcfec0d 100644 --- a/app/models/key.rb +++ b/app/models/key.rb @@ -1,6 +1,7 @@ require 'digest/md5' class Key < ActiveRecord::Base + include Gitlab::CurrentSettings include Sortable LAST_USED_AT_REFRESH_TIME = 1.day.to_i @@ -12,14 +13,18 @@ class Key < ActiveRecord::Base validates :title, presence: true, length: { maximum: 255 } + validates :key, presence: true, length: { maximum: 5000 }, format: { with: /\A(ssh|ecdsa)-.*\Z/ } + validates :fingerprint, uniqueness: true, presence: { message: 'cannot be generated' } + validate :key_meets_restrictions + delegate :name, :email, to: :user, prefix: true after_commit :add_to_shell, on: :create @@ -80,6 +85,10 @@ class Key < ActiveRecord::Base SystemHooksService.new.execute_hooks_for(self, :destroy) end + def public_key + @public_key ||= Gitlab::SSHPublicKey.new(key) + end + private def generate_fingerprint @@ -87,7 +96,27 @@ class Key < ActiveRecord::Base return unless self.key.present? - self.fingerprint = Gitlab::KeyFingerprint.new(self.key).fingerprint + self.fingerprint = public_key.fingerprint + end + + def key_meets_restrictions + restriction = current_application_settings.key_restriction_for(public_key.type) + + if restriction == ApplicationSetting::FORBIDDEN_KEY_VALUE + errors.add(:key, forbidden_key_type_message) + elsif public_key.bits < restriction + errors.add(:key, "must be at least #{restriction} bits") + end + end + + def forbidden_key_type_message + allowed_types = + current_application_settings + .allowed_key_types + .map(&:upcase) + .to_sentence(last_word_connector: ', or ', two_words_connector: ' or ') + + "type is forbidden. Must be #{allowed_types}" end def notify_user diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index ca3a1806ee8..7a817eedec2 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -605,6 +605,8 @@ class MergeRequest < ActiveRecord::Base self.merge_requests_closing_issues.delete_all closes_issues(current_user).each do |issue| + next if issue.is_a?(ExternalIssue) + self.merge_requests_closing_issues.create!(issue: issue) end 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/models/project.rb b/app/models/project.rb index b0dee5ffb89..b2d6598a30b 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -226,6 +226,7 @@ class Project < ActiveRecord::Base validates :import_url, importable_url: true, if: [:external_import?, :import_url_changed?] validates :star_count, numericality: { greater_than_or_equal_to: 0 } validate :check_limit, on: :create + validate :can_create_repository?, on: [:create, :update], if: ->(project) { !project.persisted? || project.renamed? } validate :avatar_type, if: ->(project) { project.avatar.present? && project.avatar_changed? } validates :avatar, file_size: { maximum: 200.kilobytes.to_i } @@ -476,7 +477,7 @@ class Project < ActiveRecord::Base end def repository_storage_path - Gitlab.config.repositories.storages[repository_storage]['path'] + Gitlab.config.repositories.storages[repository_storage].try(:[], 'path') end def team @@ -591,7 +592,7 @@ class Project < ActiveRecord::Base end def valid_import_url? - valid? || errors.messages[:import_url].nil? + valid?(:import_url) || errors.messages[:import_url].nil? end def create_or_update_import_data(data: nil, credentials: nil) @@ -1008,6 +1009,20 @@ class Project < ActiveRecord::Base end end + # Check if repository already exists on disk + def can_create_repository? + return false unless repository_storage_path + + expires_full_path_cache # we need to clear cache to validate renames correctly + + if gitlab_shell.exists?(repository_storage_path, "#{disk_path}.git") + errors.add(:base, 'There is already a repository with that name on disk') + return false + end + + true + end + def create_repository(force: false) # Forked import is handled asynchronously return if forked? && !force @@ -1498,6 +1513,10 @@ class Project < ActiveRecord::Base self.storage_version.nil? end + def renamed? + persisted? && path_changed? + end + private def storage diff --git a/app/models/repository.rb b/app/models/repository.rb index b3fa51a14f7..5474c8eeb68 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -908,7 +908,7 @@ class Repository committer = user_to_committer(user) - create_commit(message: commit.message, + create_commit(message: commit.cherry_pick_message(user), author: { email: commit.author_email, name: commit.author_name, diff --git a/app/models/user.rb b/app/models/user.rb index 78e7c750c3b..68ec93a3ec5 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -603,7 +603,7 @@ class User < ActiveRecord::Base end def require_personal_access_token_creation_for_git_auth? - return false if allow_password_authentication? || ldap_user? + return false if current_application_settings.password_authentication_enabled? || ldap_user? PersonalAccessTokensFinder.new(user: self, impersonation: false, state: 'active').execute.none? end diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb index 5c7c2204374..f2315bb3dbb 100644 --- a/app/models/wiki_page.rb +++ b/app/models/wiki_page.rb @@ -84,7 +84,7 @@ class WikiPage # The formatted title of this page. def title if @attributes[:title] - self.class.unhyphenize(@attributes[:title]) + CGI.unescape_html(self.class.unhyphenize(@attributes[:title])) else "" 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/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index 1486db046b5..8b967b78052 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -56,6 +56,7 @@ class IssuableBaseService < BaseService params.delete(:assignee_id) params.delete(:due_date) params.delete(:canonical_issue_id) + params.delete(:project) end filter_assignee(issuable) @@ -244,9 +245,7 @@ class IssuableBaseService < BaseService new_assignees = issuable.assignees.to_a affected_assignees = (old_assignees + new_assignees) - (old_assignees & new_assignees) - # Don't clear the project cache, because it will be handled by the - # appropriate service (close / reopen / merge / etc.). - invalidate_cache_counts(issuable, users: affected_assignees.compact, skip_project_cache: true) + invalidate_cache_counts(issuable, users: affected_assignees.compact) after_update(issuable) issuable.create_new_cross_references!(current_user) execute_hooks(issuable, 'update') @@ -340,18 +339,9 @@ class IssuableBaseService < BaseService create_labels_note(issuable, old_labels) if issuable.labels != old_labels end - def invalidate_cache_counts(issuable, users: [], skip_project_cache: false) + def invalidate_cache_counts(issuable, users: []) users.each do |user| user.public_send("invalidate_#{issuable.model_name.singular}_cache_counts") # rubocop:disable GitlabSecurity/PublicSend end - - unless skip_project_cache - case issuable - when Issue - IssuesFinder.new(nil, project_id: issuable.project_id).clear_caches! - when MergeRequest - MergeRequestsFinder.new(nil, project_id: issuable.target_project_id).clear_caches! - end - end end end diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb index 8d918ccc635..deb4990eb4f 100644 --- a/app/services/issues/update_service.rb +++ b/app/services/issues/update_service.rb @@ -6,7 +6,7 @@ module Issues handle_move_between_iids(issue) filter_spam_check_params change_issue_duplicate(issue) - update(issue) + move_issue_to_new_project(issue) || update(issue) end def before_update(issue) @@ -74,6 +74,17 @@ module Issues end end + def move_issue_to_new_project(issue) + target_project = params.delete(:target_project) + + return unless target_project && + issue.can_move?(current_user, target_project) && + target_project != issue.project + + update(issue) + Issues::MoveService.new(project, current_user).execute(issue, target_project) + end + private def get_issue_if_allowed(project, iid) diff --git a/app/services/projects/after_import_service.rb b/app/services/projects/after_import_service.rb index e6a68d983ef..3047268b2d1 100644 --- a/app/services/projects/after_import_service.rb +++ b/app/services/projects/after_import_service.rb @@ -1,7 +1,6 @@ module Projects class AfterImportService - RESERVED_REFS_REGEXP = - %r{\Arefs/(?:#{Regexp.union(*Repository::RESERVED_REFS_NAMES)})/} + RESERVED_REF_PREFIXES = Repository::RESERVED_REFS_NAMES.map { |n| File.join('refs', n, '/') } def initialize(project) @project = project @@ -9,7 +8,7 @@ module Projects def execute Projects::HousekeepingService.new(@project).execute do - repository.delete_refs(*garbage_refs) + repository.delete_all_refs_except(RESERVED_REF_PREFIXES) end rescue Projects::HousekeepingService::LeaseTaken => e Rails.logger.info( @@ -18,10 +17,6 @@ module Projects private - def garbage_refs - @garbage_refs ||= repository.all_ref_names_except(RESERVED_REFS_REGEXP) - end - def repository @repository ||= @project.repository end diff --git a/app/services/quick_actions/interpret_service.rb b/app/services/quick_actions/interpret_service.rb index c7832c47e1a..9cdb9935bea 100644 --- a/app/services/quick_actions/interpret_service.rb +++ b/app/services/quick_actions/interpret_service.rb @@ -505,6 +505,24 @@ module QuickActions end end + desc 'Move this issue to another project.' + explanation do |path_to_project| + "Moves this issue to #{path_to_project}." + end + params 'path/to/project' + condition do + issuable.is_a?(Issue) && + issuable.persisted? && + current_user.can?(:"admin_#{issuable.to_ability_name}", project) + end + command :move do |target_project_path| + target_project = Project.find_by_full_path(target_project_path) + + if target_project.present? + @updates[:target_project] = target_project + end + end + def extract_users(params) return [] if params.nil? diff --git a/app/validators/key_restriction_validator.rb b/app/validators/key_restriction_validator.rb new file mode 100644 index 00000000000..204be827941 --- /dev/null +++ b/app/validators/key_restriction_validator.rb @@ -0,0 +1,29 @@ +class KeyRestrictionValidator < ActiveModel::EachValidator + FORBIDDEN = -1 + + def self.supported_sizes(type) + Gitlab::SSHPublicKey.supported_sizes(type) + end + + def self.supported_key_restrictions(type) + [0, *supported_sizes(type), FORBIDDEN] + end + + def validate_each(record, attribute, value) + unless valid_restriction?(value) + record.errors.add(attribute, "must be forbidden, allowed, or one of these sizes: #{supported_sizes_message}") + end + end + + private + + def supported_sizes_message + sizes = self.class.supported_sizes(options[:type]) + sizes.to_sentence(last_word_connector: ', or ', two_words_connector: ' or ') + end + + def valid_restriction?(value) + choices = self.class.supported_key_restrictions(options[:type]) + choices.include?(value) + end +end diff --git a/app/views/admin/application_settings/_form.html.haml b/app/views/admin/application_settings/_form.html.haml index 5066dcb109b..85524091204 100644 --- a/app/views/admin/application_settings/_form.html.haml +++ b/app/views/admin/application_settings/_form.html.haml @@ -42,12 +42,7 @@ = link_to "(?)", help_page_path("integration/bitbucket") and GitLab.com = link_to "(?)", help_page_path("integration/gitlab") - .form-group - %label.control-label.col-sm-2 Enabled Git access protocols - .col-sm-10 - = select(:application_setting, :enabled_git_access_protocol, [['Both SSH and HTTP(S)', nil], ['Only SSH', 'ssh'], ['Only HTTP(S)', 'http']], {}, class: 'form-control') - %span.help-block#clone-protocol-help - Allow only the selected protocols to be used for Git access. + .form-group .col-sm-offset-2.col-sm-10 .checkbox @@ -55,6 +50,20 @@ = f.check_box :project_export_enabled Project export enabled + .form-group + %label.control-label.col-sm-2 Enabled Git access protocols + .col-sm-10 + = select(:application_setting, :enabled_git_access_protocol, [['Both SSH and HTTP(S)', nil], ['Only SSH', 'ssh'], ['Only HTTP(S)', 'http']], {}, class: 'form-control') + %span.help-block#clone-protocol-help + Allow only the selected protocols to be used for Git access. + + - ApplicationSetting::SUPPORTED_KEY_TYPES.each do |type| + - field_name = :"#{type}_key_restriction" + .form-group + = f.label field_name, "#{type.upcase} SSH keys", class: 'control-label col-sm-2' + .col-sm-10 + = f.select field_name, key_restriction_options_for_select(type), {}, class: 'form-control' + %fieldset %legend Account and Limit Settings .form-group @@ -153,7 +162,7 @@ .checkbox = f.label :password_authentication_enabled do = f.check_box :password_authentication_enabled - Password authentication enabled + Sign-in enabled - if omniauth_enabled? && button_based_providers.any? .form-group = f.label :enabled_oauth_sign_in_sources, 'Enabled OAuth sign-in sources', class: 'control-label col-sm-2' diff --git a/app/views/ci/status/_dropdown_graph_badge.html.haml b/app/views/ci/status/_dropdown_graph_badge.html.haml index 8ed23ac4919..dcfb7f0c32d 100644 --- a/app/views/ci/status/_dropdown_graph_badge.html.haml +++ b/app/views/ci/status/_dropdown_graph_badge.html.haml @@ -6,14 +6,14 @@ - tooltip = "#{subject.name} - #{status.label}" - if status.has_details? - = link_to status.details_path, class: 'mini-pipeline-graph-dropdown-item', data: { toggle: 'tooltip', title: tooltip } do + = link_to status.details_path, class: 'mini-pipeline-graph-dropdown-item', data: { toggle: 'tooltip', title: tooltip, container: 'body' } do %span{ class: klass }= custom_icon(status.icon) %span.ci-build-text= subject.name - else - .mini-pipeline-graph-dropdown-item{ data: { toggle: 'tooltip', title: tooltip } } + .menu-item.mini-pipeline-graph-dropdown-item{ data: { toggle: 'tooltip', title: tooltip, container: 'body' } } %span{ class: klass }= custom_icon(status.icon) %span.ci-build-text= subject.name - if status.has_action? - = link_to status.action_path, class: 'ci-action-icon-wrapper js-ci-action-icon', method: status.action_method, data: { toggle: 'tooltip', title: status.action_title } do + = link_to status.action_path, class: 'ci-action-icon-wrapper js-ci-action-icon', method: status.action_method, data: { toggle: 'tooltip', title: status.action_title, container: 'body' } do = custom_icon(status.action_icon) 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/groups/issues.html.haml b/app/views/groups/issues.html.haml index 12bc092d216..837ef385dd5 100644 --- a/app/views/groups/issues.html.haml +++ b/app/views/groups/issues.html.haml @@ -12,6 +12,8 @@ - content_for :breadcrumbs_extra do = link_to params.merge(rss_url_options), class: 'btn btn-default append-right-10' do = icon('rss') + %span.icon-label + Subscribe = render 'shared/new_project_item_select', path: 'issues/new', label: "New issue", type: :issues - if group_issues_exists diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index b32cfe158bb..1d875f81041 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -74,8 +74,6 @@ = link_to "Profile", current_user, class: 'profile-link', data: { user: current_user.username } %li = link_to "Settings", profile_path - %li - = link_to "Turn on new navigation", profile_preferences_path(anchor: "new-navigation") %li.divider %li = link_to "Sign out", destroy_user_session_path, method: :delete, class: "sign-out-link" diff --git a/app/views/layouts/header/_new.html.haml b/app/views/layouts/header/_new.html.haml index 2c1c23d6ea9..c84d7053cd6 100644 --- a/app/views/layouts/header/_new.html.haml +++ b/app/views/layouts/header/_new.html.haml @@ -4,7 +4,7 @@ .header-content .title-container %h1.title - = link_to root_path, title: 'Dashboard' do + = link_to root_path, title: 'Dashboard', id: 'logo' do = brand_header_logo %span.logo-text.hidden-xs = render 'shared/logo_type.svg' @@ -37,13 +37,13 @@ data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do = icon('tachometer fw') %li - = link_to assigned_issues_dashboard_path, title: 'Issues', aria: { label: "Issues" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do + = link_to assigned_issues_dashboard_path, title: 'Issues', class: 'dashboard-shortcuts-issues', aria: { label: "Issues" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do = custom_icon('issues') - issues_count = assigned_issuables_count(:issues) %span.badge.issues-count{ class: ('hidden' if issues_count.zero?) } = number_with_delimiter(issues_count) %li - = link_to assigned_mrs_dashboard_path, title: 'Merge requests', aria: { label: "Merge requests" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do + = link_to assigned_mrs_dashboard_path, title: 'Merge requests', class: 'dashboard-shortcuts-merge_requests', aria: { label: "Merge requests" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do = custom_icon('mr_bold') - merge_requests_count = assigned_issuables_count(:merge_requests) %span.badge.merge-requests-count{ class: ('hidden' if merge_requests_count.zero?) } @@ -68,8 +68,6 @@ = link_to "Profile", current_user, class: 'profile-link', data: { user: current_user.username } %li = link_to "Settings", profile_path - %li - = link_to "Turn off new navigation", profile_preferences_path(anchor: "new-navigation") %li.divider %li = link_to "Sign out", destroy_user_session_path, method: :delete, class: "sign-out-link" diff --git a/app/views/layouts/nav/_profile.html.haml b/app/views/layouts/nav/_profile.html.haml index 26d9640e98a..448f6abedf2 100644 --- a/app/views/layouts/nav/_profile.html.haml +++ b/app/views/layouts/nav/_profile.html.haml @@ -29,7 +29,7 @@ = link_to profile_emails_path, title: 'Emails' do %span Emails - - if current_user.allow_password_authentication? + - unless current_user.ldap_user? = nav_link(controller: :passwords) do = link_to edit_profile_password_path, title: 'Password' do %span diff --git a/app/views/profiles/keys/_key.html.haml b/app/views/profiles/keys/_key.html.haml index d2a60ac2867..103446243e5 100644 --- a/app/views/profiles/keys/_key.html.haml +++ b/app/views/profiles/keys/_key.html.haml @@ -1,6 +1,12 @@ %li.key-list-item .pull-left.append-right-10 - = icon 'key', class: "settings-list-icon hidden-xs" + - if key.valid? + = icon 'key', class: 'settings-list-icon hidden-xs' + - else + = icon 'exclamation-triangle', class: 'settings-list-icon hidden-xs has-tooltip', + title: key.errors.full_messages.join(', ') + + .key-list-item-info = link_to path_to_key(key, is_admin), class: "title" do = key.title diff --git a/app/views/profiles/keys/_key_details.html.haml b/app/views/profiles/keys/_key_details.html.haml index d44603c638c..77521417f47 100644 --- a/app/views/profiles/keys/_key_details.html.haml +++ b/app/views/profiles/keys/_key_details.html.haml @@ -16,6 +16,7 @@ %strong= @key.last_used_at.try(:to_s, :medium) || 'N/A' .col-md-8 + = form_errors(@key, type: 'key') unless @key.valid? %p %span.light Fingerprint: %code.key-fingerprint= @key.fingerprint diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml index f08dcc0c242..9e7fe556d88 100644 --- a/app/views/profiles/preferences/show.html.haml +++ b/app/views/profiles/preferences/show.html.haml @@ -18,26 +18,6 @@ = scheme.name .col-sm-12 %hr - .col-lg-4.profile-settings-sidebar#new-navigation - %h4.prepend-top-0 - New Navigation - %p - This setting allows you to turn on or off the new upcoming navigation concept. - .col-lg-8.syntax-theme - .nav-wip - %p - The new navigation is currently a work-in-progress concept and is currently only usable on wide-screens. There are a number of improvements that we are working on in order to further refine our navigation. - %p - %a{ href: 'https://gitlab.com/gitlab-org/gitlab-ce/issues/32794', target: 'blank' } Learn more - about the improvements that are coming soon! - = label_tag do - .preview= image_tag "old_nav.png" - %input.js-experiment-feature-toggle{ type: "radio", value: "false", name: "new_nav", checked: !show_new_nav? } - Old - = label_tag do - .preview= image_tag "new_nav.png" - %input.js-experiment-feature-toggle{ type: "radio", value: "true", name: "new_nav", checked: show_new_nav? } - New .col-sm-12 %hr .col-lg-4.profile-settings-sidebar 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 |