diff options
Diffstat (limited to 'app')
42 files changed, 600 insertions, 95 deletions
diff --git a/app/assets/javascripts/diff.js b/app/assets/javascripts/diff.js index ae8338f5fd2..9ddfdb6ae21 100644 --- a/app/assets/javascripts/diff.js +++ b/app/assets/javascripts/diff.js @@ -17,7 +17,8 @@ class Diff { } }); - FilesCommentButton.init($diffFile); + const tab = document.getElementById('diffs'); + if (!tab || (tab && tab.dataset && tab.dataset.isLocked !== '')) FilesCommentButton.init($diffFile); $diffFile.each((index, file) => new gl.ImageFile(file)); diff --git a/app/assets/javascripts/notes/components/issue_comment_form.vue b/app/assets/javascripts/notes/components/issue_comment_form.vue index 1a7da84a424..ab8516296a8 100644 --- a/app/assets/javascripts/notes/components/issue_comment_form.vue +++ b/app/assets/javascripts/notes/components/issue_comment_form.vue @@ -7,10 +7,12 @@ 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 issueWarning from '../../vue_shared/components/issue/issue_warning.vue'; import issueNoteSignedOutWidget from './issue_note_signed_out_widget.vue'; + import issueDiscussionLockedWidget from './issue_discussion_locked_widget.vue'; import markdownField from '../../vue_shared/components/markdown/field.vue'; import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; + import issuableStateMixin from '../mixins/issuable_state'; export default { name: 'issueCommentForm', @@ -26,8 +28,9 @@ }; }, components: { - confidentialIssue, + issueWarning, issueNoteSignedOutWidget, + issueDiscussionLockedWidget, markdownField, userAvatarLink, }, @@ -55,6 +58,9 @@ isIssueOpen() { return this.issueState === constants.OPENED || this.issueState === constants.REOPENED; }, + canCreateNote() { + return this.getIssueData.current_user.can_create_note; + }, issueActionButtonTitle() { if (this.note.length) { const actionText = this.isIssueOpen ? 'close' : 'reopen'; @@ -90,9 +96,6 @@ endpoint() { return this.getIssueData.create_note_path; }, - isConfidentialIssue() { - return this.getIssueData.confidential; - }, }, methods: { ...mapActions([ @@ -220,6 +223,9 @@ }); }, }, + mixins: [ + issuableStateMixin, + ], mounted() { // jQuery is needed here because it is a custom event being dispatched with jQuery. $(document).on('issuable:change', (e, isClosed) => { @@ -235,6 +241,7 @@ <template> <div> <issue-note-signed-out-widget v-if="!isLoggedIn" /> + <issue-discussion-locked-widget v-else-if="!canCreateNote" /> <ul v-else class="notes notes-form timeline"> @@ -253,15 +260,22 @@ <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" /> + class="new-note js-quick-submit common-note-form gfm-form js-main-target-form" + > + <div class="error-alert"></div> + + <issue-warning + v-if="hasWarning(getIssueData)" + :is-locked="isLocked(getIssueData)" + :is-confidential="isConfidential(getIssueData)" + /> + <markdown-field :markdown-preview-path="markdownPreviewPath" :markdown-docs-path="markdownDocsPath" :quick-actions-docs-path="quickActionsDocsPath" :add-spacing-classes="false" - :is-confidential-issue="isConfidentialIssue" ref="markdownField"> <textarea id="note-body" diff --git a/app/assets/javascripts/notes/components/issue_discussion_locked_widget.vue b/app/assets/javascripts/notes/components/issue_discussion_locked_widget.vue new file mode 100644 index 00000000000..e73ec2aaf71 --- /dev/null +++ b/app/assets/javascripts/notes/components/issue_discussion_locked_widget.vue @@ -0,0 +1,19 @@ +<script> + export default { + computed: { + lockIcon() { + return gl.utils.spriteIcon('lock'); + }, + }, + }; + +</script> + +<template> + <div class="disabled-comment text-center"> + <span class="issuable-note-warning"> + <span class="icon" v-html="lockIcon"></span> + <span>This issue is locked. Only <b>project members</b> can comment.</span> + </span> + </div> +</template> diff --git a/app/assets/javascripts/notes/components/issue_note_form.vue b/app/assets/javascripts/notes/components/issue_note_form.vue index 626c0f2ce18..e2539d6b89d 100644 --- a/app/assets/javascripts/notes/components/issue_note_form.vue +++ b/app/assets/javascripts/notes/components/issue_note_form.vue @@ -1,8 +1,9 @@ <script> import { mapGetters } from 'vuex'; import eventHub from '../event_hub'; - import confidentialIssue from '../../vue_shared/components/issue/confidential_issue_warning.vue'; + import issueWarning from '../../vue_shared/components/issue/issue_warning.vue'; import markdownField from '../../vue_shared/components/markdown/field.vue'; + import issuableStateMixin from '../mixins/issuable_state'; export default { name: 'issueNoteForm', @@ -39,12 +40,13 @@ }; }, components: { - confidentialIssue, + issueWarning, markdownField, }, computed: { ...mapGetters([ 'getDiscussionLastNote', + 'getIssueData', 'getIssueDataByProp', 'getNotesDataByProp', 'getUserDataByProp', @@ -67,9 +69,6 @@ isDisabled() { return !this.note.length || this.isSubmitting; }, - isConfidentialIssue() { - return this.getIssueDataByProp('confidential'); - }, }, methods: { handleUpdate() { @@ -95,6 +94,9 @@ this.$emit('cancelFormEdition', shouldConfirm, this.noteBody !== this.note); }, }, + mixins: [ + issuableStateMixin, + ], mounted() { this.$refs.textarea.focus(); }, @@ -125,7 +127,13 @@ <div class="flash-container timeline-content"></div> <form class="edit-note common-note-form js-quick-submit gfm-form"> - <confidentialIssue v-if="isConfidentialIssue" /> + + <issue-warning + v-if="hasWarning(getIssueData)" + :is-locked="isLocked(getIssueData)" + :is-confidential="isConfidential(getIssueData)" + /> + <markdown-field :markdown-preview-path="markdownPreviewPath" :markdown-docs-path="markdownDocsPath" diff --git a/app/assets/javascripts/notes/mixins/issuable_state.js b/app/assets/javascripts/notes/mixins/issuable_state.js new file mode 100644 index 00000000000..97f3ea0d5de --- /dev/null +++ b/app/assets/javascripts/notes/mixins/issuable_state.js @@ -0,0 +1,15 @@ +export default { + methods: { + isConfidential(issue) { + return !!issue.confidential; + }, + + isLocked(issue) { + return !!issue.discussion_locked; + }, + + hasWarning(issue) { + return this.isConfidential(issue) || this.isLocked(issue); + }, + }, +}; diff --git a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue b/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue index 8e7abdbffef..f2b1099a678 100644 --- a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue +++ b/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue @@ -47,9 +47,9 @@ export default { </script> <template> - <div class="block confidentiality"> + <div class="block issuable-sidebar-item confidentiality"> <div class="sidebar-collapsed-icon"> - <i class="fa" :class="faEye" aria-hidden="true" data-hidden="true"></i> + <i class="fa" :class="faEye" aria-hidden="true"></i> </div> <div class="title hide-collapsed"> Confidentiality @@ -62,19 +62,19 @@ export default { Edit </a> </div> - <div class="value confidential-value hide-collapsed"> + <div class="value sidebar-item-value hide-collapsed"> <editForm v-if="edit" :toggle-form="toggleForm" :is-confidential="isConfidential" :update-confidential-attribute="updateConfidentialAttribute" /> - <div v-if="!isConfidential" class="no-value confidential-value"> - <i class="fa fa-eye is-not-confidential"></i> + <div v-if="!isConfidential" class="no-value sidebar-item-value"> + <i class="fa fa-eye sidebar-item-icon"></i> Not confidential </div> - <div v-else class="value confidential-value hide-collapsed"> - <i aria-hidden="true" data-hidden="true" class="fa fa-eye-slash is-confidential"></i> + <div v-else class="value sidebar-item-value hide-collapsed"> + <i aria-hidden="true" class="fa fa-eye-slash sidebar-item-icon is-active"></i> This issue is confidential </div> </div> diff --git a/app/assets/javascripts/sidebar/components/confidential/edit_form.vue b/app/assets/javascripts/sidebar/components/confidential/edit_form.vue index d578b663a54..dd17b5abd46 100644 --- a/app/assets/javascripts/sidebar/components/confidential/edit_form.vue +++ b/app/assets/javascripts/sidebar/components/confidential/edit_form.vue @@ -2,9 +2,6 @@ import editFormButtons from './edit_form_buttons.vue'; export default { - components: { - editFormButtons, - }, props: { isConfidential: { required: true, @@ -19,12 +16,16 @@ export default { type: Function, }, }, + + components: { + editFormButtons, + }, }; </script> <template> <div class="dropdown open"> - <div class="dropdown-menu confidential-warning-message"> + <div class="dropdown-menu sidebar-item-warning-message"> <div> <p v-if="!isConfidential"> You are going to turn on the confidentiality. This means that only team members with diff --git a/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue b/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue index 97af4a3f505..7ed0619ee6b 100644 --- a/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue +++ b/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue @@ -15,7 +15,7 @@ export default { }, }, computed: { - onOrOff() { + toggleButtonText() { return this.isConfidential ? 'Turn Off' : 'Turn On'; }, updateConfidentialBool() { @@ -26,7 +26,7 @@ export default { </script> <template> - <div class="confidential-warning-message-actions"> + <div class="sidebar-item-warning-message-actions"> <button type="button" class="btn btn-default append-right-10" @@ -39,7 +39,7 @@ export default { class="btn btn-close" @click.prevent="updateConfidentialAttribute(updateConfidentialBool)" > - {{ onOrOff }} + {{ toggleButtonText }} </button> </div> </template> diff --git a/app/assets/javascripts/sidebar/components/lock/edit_form.vue b/app/assets/javascripts/sidebar/components/lock/edit_form.vue new file mode 100644 index 00000000000..c7a6edc7c70 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/lock/edit_form.vue @@ -0,0 +1,61 @@ +<script> +import editFormButtons from './edit_form_buttons.vue'; +import issuableMixin from '../../../vue_shared/mixins/issuable'; + +export default { + props: { + isLocked: { + required: true, + type: Boolean, + }, + + toggleForm: { + required: true, + type: Function, + }, + + updateLockedAttribute: { + required: true, + type: Function, + }, + + issuableType: { + required: true, + type: String, + }, + }, + + mixins: [ + issuableMixin, + ], + + components: { + editFormButtons, + }, +}; +</script> + +<template> + <div class="dropdown open"> + <div class="dropdown-menu sidebar-item-warning-message"> + <p class="text" v-if="isLocked"> + Unlock this {{ issuableDisplayName(issuableType) }}? + <strong>Everyone</strong> + will be able to comment. + </p> + + <p class="text" v-else> + Lock this {{ issuableDisplayName(issuableType) }}? + Only + <strong>project members</strong> + will be able to comment. + </p> + + <edit-form-buttons + :is-locked="isLocked" + :toggle-form="toggleForm" + :update-locked-attribute="updateLockedAttribute" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue b/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue new file mode 100644 index 00000000000..c3a553a7605 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue @@ -0,0 +1,50 @@ +<script> +export default { + props: { + isLocked: { + required: true, + type: Boolean, + }, + + toggleForm: { + required: true, + type: Function, + }, + + updateLockedAttribute: { + required: true, + type: Function, + }, + }, + + computed: { + buttonText() { + return this.isLocked ? this.__('Unlock') : this.__('Lock'); + }, + + toggleLock() { + return !this.isLocked; + }, + }, +}; +</script> + +<template> + <div class="sidebar-item-warning-message-actions"> + <button + type="button" + class="btn btn-default append-right-10" + @click="toggleForm" + > + {{ __('Cancel') }} + </button> + + <button + type="button" + class="btn btn-close" + @click.prevent="updateLockedAttribute(toggleLock)" + > + {{ buttonText }} + </button> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue b/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue new file mode 100644 index 00000000000..c4b2900e020 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue @@ -0,0 +1,120 @@ +<script> +/* global Flash */ +import editForm from './edit_form.vue'; +import issuableMixin from '../../../vue_shared/mixins/issuable'; + +export default { + props: { + isLocked: { + required: true, + type: Boolean, + }, + + isEditable: { + required: true, + type: Boolean, + }, + + mediator: { + required: true, + type: Object, + validator(mediatorObject) { + return mediatorObject.service && mediatorObject.service.update && mediatorObject.store; + }, + }, + + issuableType: { + required: true, + type: String, + }, + }, + + mixins: [ + issuableMixin, + ], + + components: { + editForm, + }, + + computed: { + lockIconClass() { + return this.isLocked ? 'fa-lock' : 'fa-unlock'; + }, + + isLockDialogOpen() { + return this.mediator.store.isLockDialogOpen; + }, + }, + + methods: { + toggleForm() { + this.mediator.store.isLockDialogOpen = !this.mediator.store.isLockDialogOpen; + }, + + updateLockedAttribute(locked) { + this.mediator.service.update(this.issuableType, { + discussion_locked: locked, + }) + .then(() => location.reload()) + .catch(() => Flash(this.__(`Something went wrong trying to change the locked state of this ${this.issuableDisplayName(this.issuableType)}`))); + }, + }, +}; +</script> + +<template> + <div class="block issuable-sidebar-item lock"> + <div class="sidebar-collapsed-icon"> + <i + class="fa" + :class="lockIconClass" + aria-hidden="true" + ></i> + </div> + + <div class="title hide-collapsed"> + Lock {{issuableDisplayName(issuableType) }} + <button + v-if="isEditable" + class="pull-right lock-edit btn btn-blank" + type="button" + @click.prevent="toggleForm" + > + {{ __('Edit') }} + </button> + </div> + + <div class="value sidebar-item-value hide-collapsed"> + <edit-form + v-if="isLockDialogOpen" + :toggle-form="toggleForm" + :is-locked="isLocked" + :update-locked-attribute="updateLockedAttribute" + :issuable-type="issuableType" + /> + + <div + v-if="isLocked" + class="value sidebar-item-value" + > + <i + aria-hidden="true" + class="fa fa-lock sidebar-item-icon is-active" + ></i> + {{ __('Locked') }} + </div> + + <div + v-else + class="no-value sidebar-item-value hide-collapsed" + > + <i + aria-hidden="true" + class="fa fa-unlock sidebar-item-icon" + ></i> + {{ __('Unlocked') }} + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/sidebar_bundle.js b/app/assets/javascripts/sidebar/sidebar_bundle.js index 3d8972050a9..09b9d75c02d 100644 --- a/app/assets/javascripts/sidebar/sidebar_bundle.js +++ b/app/assets/javascripts/sidebar/sidebar_bundle.js @@ -1,46 +1,76 @@ import Vue from 'vue'; -import sidebarTimeTracking from './components/time_tracking/sidebar_time_tracking'; -import sidebarAssignees from './components/assignees/sidebar_assignees'; -import confidential from './components/confidential/confidential_issue_sidebar.vue'; +import SidebarTimeTracking from './components/time_tracking/sidebar_time_tracking'; +import SidebarAssignees from './components/assignees/sidebar_assignees'; +import ConfidentialIssueSidebar from './components/confidential/confidential_issue_sidebar.vue'; import SidebarMoveIssue from './lib/sidebar_move_issue'; +import LockIssueSidebar from './components/lock/lock_issue_sidebar.vue'; +import Translate from '../vue_shared/translate'; import Mediator from './sidebar_mediator'; +Vue.use(Translate); + +function mountConfidentialComponent(mediator) { + const el = document.getElementById('js-confidential-entry-point'); + + if (!el) return; + + const dataNode = document.getElementById('js-confidential-issue-data'); + const initialData = JSON.parse(dataNode.innerHTML); + + const ConfidentialComp = Vue.extend(ConfidentialIssueSidebar); + + new ConfidentialComp({ + propsData: { + isConfidential: initialData.is_confidential, + isEditable: initialData.is_editable, + service: mediator.service, + }, + }).$mount(el); +} + +function mountLockComponent(mediator) { + const el = document.getElementById('js-lock-entry-point'); + + if (!el) return; + + const dataNode = document.getElementById('js-lock-issue-data'); + const initialData = JSON.parse(dataNode.innerHTML); + + const LockComp = Vue.extend(LockIssueSidebar); + + new LockComp({ + propsData: { + isLocked: initialData.is_locked, + isEditable: initialData.is_editable, + mediator, + issuableType: gl.utils.isInIssuePage() ? 'issue' : 'merge_request', + }, + }).$mount(el); +} + function domContentLoaded() { const sidebarOptions = JSON.parse(document.querySelector('.js-sidebar-options').innerHTML); const mediator = new Mediator(sidebarOptions); mediator.fetch(); - const sidebarAssigneesEl = document.querySelector('#js-vue-sidebar-assignees'); - const confidentialEl = document.querySelector('#js-confidential-entry-point'); + const sidebarAssigneesEl = document.getElementById('js-vue-sidebar-assignees'); // Only create the sidebarAssignees vue app if it is found in the DOM // We currently do not use sidebarAssignees for the MR page if (sidebarAssigneesEl) { - new Vue(sidebarAssignees).$mount(sidebarAssigneesEl); + new Vue(SidebarAssignees).$mount(sidebarAssigneesEl); } - if (confidentialEl) { - const dataNode = document.getElementById('js-confidential-issue-data'); - const initialData = JSON.parse(dataNode.innerHTML); + mountConfidentialComponent(mediator); + mountLockComponent(mediator); - const ConfidentialComp = Vue.extend(confidential); - - new ConfidentialComp({ - propsData: { - isConfidential: initialData.is_confidential, - isEditable: initialData.is_editable, - service: mediator.service, - }, - }).$mount(confidentialEl); - - new SidebarMoveIssue( - mediator, - $('.js-move-issue'), - $('.js-move-issue-confirmation-button'), - ).init(); - } + new SidebarMoveIssue( + mediator, + $('.js-move-issue'), + $('.js-move-issue-confirmation-button'), + ).init(); - new Vue(sidebarTimeTracking).$mount('#issuable-time-tracker'); + new Vue(SidebarTimeTracking).$mount('#issuable-time-tracker'); } document.addEventListener('DOMContentLoaded', domContentLoaded); diff --git a/app/assets/javascripts/sidebar/stores/sidebar_store.js b/app/assets/javascripts/sidebar/stores/sidebar_store.js index cc04a2a3fcf..d5d04103f3f 100644 --- a/app/assets/javascripts/sidebar/stores/sidebar_store.js +++ b/app/assets/javascripts/sidebar/stores/sidebar_store.js @@ -15,6 +15,7 @@ export default class SidebarStore { }; this.autocompleteProjects = []; this.moveToProjectId = 0; + this.isLockDialogOpen = false; SidebarStore.singleton = this; } 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 deleted file mode 100644 index 397d16331d5..00000000000 --- a/app/assets/javascripts/vue_shared/components/issue/confidential_issue_warning.vue +++ /dev/null @@ -1,16 +0,0 @@ -<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/issue/issue_warning.vue b/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue new file mode 100644 index 00000000000..16c0a8efcd2 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue @@ -0,0 +1,55 @@ +<script> + export default { + props: { + isLocked: { + type: Boolean, + default: false, + required: false, + }, + + isConfidential: { + type: Boolean, + default: false, + required: false, + }, + }, + + computed: { + iconClass() { + return { + 'fa-eye-slash': this.isConfidential, + 'fa-lock': this.isLocked, + }; + }, + + isLockedAndConfidential() { + return this.isConfidential && this.isLocked; + }, + }, + }; +</script> +<template> + <div class="issuable-note-warning"> + <i + aria-hidden="true" + class="fa icon" + :class="iconClass" + v-if="!isLockedAndConfidential" + ></i> + + <span v-if="isLockedAndConfidential"> + {{ __('This issue is confidential and locked.') }} + {{ __('People without permission will never get a notification and won\'t be able to comment.') }} + </span> + + <span v-else-if="isConfidential"> + {{ __('This is a confidential issue.') }} + {{ __('Your comment will not be visible to the public.') }} + </span> + + <span v-else-if="isLocked"> + {{ __('This issue is locked.') }} + {{ __('Only project members can comment.') }} + </span> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/mixins/issuable.js b/app/assets/javascripts/vue_shared/mixins/issuable.js new file mode 100644 index 00000000000..263361587e0 --- /dev/null +++ b/app/assets/javascripts/vue_shared/mixins/issuable.js @@ -0,0 +1,9 @@ +export default { + methods: { + issuableDisplayName(issuableType) { + const displayName = issuableType.replace(/_/, ' '); + + return this.__ ? this.__(displayName) : displayName; + }, + }, +}; diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss index 74b846217bb..e8037c77aab 100644 --- a/app/assets/stylesheets/framework.scss +++ b/app/assets/stylesheets/framework.scss @@ -40,6 +40,7 @@ @import "framework/tables"; @import "framework/notes"; @import "framework/timeline"; +@import "framework/tooltips"; @import "framework/typography"; @import "framework/zen"; @import "framework/blank"; diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss index d178bc17462..c77160a678b 100644 --- a/app/assets/stylesheets/framework/buttons.scss +++ b/app/assets/stylesheets/framework/buttons.scss @@ -381,7 +381,11 @@ background: transparent; border: 0; + &:hover, + &:active, &:focus { outline: 0; + background: transparent; + box-shadow: none; } } diff --git a/app/assets/stylesheets/framework/gitlab-theme.scss b/app/assets/stylesheets/framework/gitlab-theme.scss index 6b69e8018be..a6bdcf46aa7 100644 --- a/app/assets/stylesheets/framework/gitlab-theme.scss +++ b/app/assets/stylesheets/framework/gitlab-theme.scss @@ -95,7 +95,7 @@ } } - .title { + .navbar .title { > a { &:hover, &:focus { diff --git a/app/assets/stylesheets/framework/tooltips.scss b/app/assets/stylesheets/framework/tooltips.scss new file mode 100644 index 00000000000..93baf73cb78 --- /dev/null +++ b/app/assets/stylesheets/framework/tooltips.scss @@ -0,0 +1,7 @@ +.tooltip-inner { + font-size: $tooltip-font-size; + border-radius: $border-radius-default; + line-height: 16px; + font-weight: $gl-font-weight-normal; + padding: $gl-btn-padding; +} diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 60260355765..5ab40947da9 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -203,6 +203,11 @@ $code_font_size: 12px; $code_line_height: 1.6; /* + * Tooltips + */ +$tooltip-font-size: 12px; + +/* * Padding */ $gl-padding: 16px; @@ -700,3 +705,9 @@ Project Templates Icons $rails: #c00; $node: #353535; $java: #70ad51; + +/* +Issuable warning +*/ +$issuable-warning-size: 24px; +$issuable-warning-icon-margin: 4px; diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 7eb28354e6d..db3b7e89d7b 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -5,27 +5,29 @@ margin-right: auto; } -.is-confidential { +.issuable-warning-icon { color: $orange-600; background-color: $orange-100; border-radius: $border-radius-default; padding: 5px; - margin: 0 3px 0 -4px; + margin: 0 $btn-side-margin 0 0; + width: $issuable-warning-size; + height: $issuable-warning-size; + text-align: center; + + &:first-of-type { + margin-right: $issuable-warning-icon-margin; + } } -.is-not-confidential { +.sidebar-item-icon { border-radius: $border-radius-default; padding: 5px; margin: 0 3px 0 -4px; -} - -.confidentiality { - .is-not-confidential { - margin: auto; - } - .is-confidential { - margin: auto; + &.is-active { + color: $orange-600; + background-color: $orange-50; } } diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss index 74d9acb5490..420bca9ece5 100644 --- a/app/assets/stylesheets/pages/note_form.scss +++ b/app/assets/stylesheets/pages/note_form.scss @@ -101,7 +101,7 @@ } } -.confidential-issue-warning { +.issuable-note-warning { color: $orange-600; background-color: $orange-100; border-radius: $border-radius-default $border-radius-default 0 0; @@ -110,28 +110,52 @@ padding: 3px 12px; margin: auto; align-items: center; + + .icon { + margin-right: $issuable-warning-icon-margin; + } +} + +.disabled-comment .issuable-note-warning { + border: none; + border-radius: $label-border-radius; + padding-top: $gl-vert-padding; + padding-bottom: $gl-vert-padding; + + .icon svg { + position: relative; + top: 2px; + margin-right: $btn-xs-side-margin; + width: $gl-font-size; + height: $gl-font-size; + fill: $orange-600; + } } -.confidential-value { +.sidebar-item-value { .fa { background-color: inherit; } } -.confidential-warning-message { +.sidebar-item-warning-message { line-height: 1.5; padding: 16px; - .confidential-warning-message-actions { + .text { + color: $text-color; + } + + .sidebar-item-warning-message-actions { display: flex; - button { + .btn { flex-grow: 1; } } } -.confidential-issue-warning + .md-area { +.issuable-note-warning + .md-area { border-top-left-radius: 0; border-top-right-radius: 0; } diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 46d31e41ada..925fe4513ee 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -703,6 +703,12 @@ ul.notes { color: $note-disabled-comment-color; padding: 90px 0; + &.discussion-locked { + border: none; + background-color: $white-light; + } + + a { color: $gl-link-color; } diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index ee6e6f80cdd..b7a108a0ebd 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -278,6 +278,7 @@ class Projects::IssuesController < Projects::ApplicationController state_event task_num lock_version + discussion_locked ] + [{ label_ids: [], assignee_ids: [] }] end diff --git a/app/controllers/projects/merge_requests/application_controller.rb b/app/controllers/projects/merge_requests/application_controller.rb index 6602b204fcb..eb7d7bf374c 100644 --- a/app/controllers/projects/merge_requests/application_controller.rb +++ b/app/controllers/projects/merge_requests/application_controller.rb @@ -34,6 +34,7 @@ class Projects::MergeRequests::ApplicationController < Projects::ApplicationCont :target_project_id, :task_num, :title, + :discussion_locked, label_ids: [] ] diff --git a/app/controllers/projects/notes_controller.rb b/app/controllers/projects/notes_controller.rb index 41a13f6f577..ef7d047b1ad 100644 --- a/app/controllers/projects/notes_controller.rb +++ b/app/controllers/projects/notes_controller.rb @@ -66,7 +66,16 @@ class Projects::NotesController < Projects::ApplicationController params.merge(last_fetched_at: last_fetched_at) end + def authorize_admin_note! + return access_denied! unless can?(current_user, :admin_note, note) + end + def authorize_resolve_note! return access_denied! unless can?(current_user, :resolve_note, note) end + + def authorize_create_note! + return unless noteable.lockable? + access_denied! unless can?(current_user, :create_note, noteable) + end end diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb index ce028195e51..c219aa3d6a9 100644 --- a/app/helpers/notes_helper.rb +++ b/app/helpers/notes_helper.rb @@ -130,8 +130,12 @@ module NotesHelper end def can_create_note? + issuable = @issue || @merge_request + if @snippet.is_a?(PersonalSnippet) can?(current_user, :comment_personal_snippet, @snippet) + elsif issuable + can?(current_user, :create_note, issuable) else can?(current_user, :create_note, @project) end diff --git a/app/helpers/system_note_helper.rb b/app/helpers/system_note_helper.rb index d7eaf6ce24d..00fe67d6ffb 100644 --- a/app/helpers/system_note_helper.rb +++ b/app/helpers/system_note_helper.rb @@ -19,7 +19,9 @@ module SystemNoteHelper 'discussion' => 'comment', 'moved' => 'arrow-right', 'outdated' => 'pencil', - 'duplicate' => 'issue-duplicate' + 'duplicate' => 'issue-duplicate', + 'locked' => 'lock', + 'unlocked' => 'lock-open' }.freeze def system_note_icon_name(note) diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb index 1c4ddabcad5..5d75b2aa6a3 100644 --- a/app/models/concerns/noteable.rb +++ b/app/models/concerns/noteable.rb @@ -74,4 +74,8 @@ module Noteable def discussions_can_be_resolved_by?(user) discussions_to_be_resolved.all? { |discussion| discussion.can_resolve?(user) } end + + def lockable? + [MergeRequest, Issue].include?(self.class) + end end diff --git a/app/models/system_note_metadata.rb b/app/models/system_note_metadata.rb index 0b33e45473b..1f9f8d7286b 100644 --- a/app/models/system_note_metadata.rb +++ b/app/models/system_note_metadata.rb @@ -2,7 +2,7 @@ class SystemNoteMetadata < ActiveRecord::Base ICON_TYPES = %w[ commit description merge confidential visible label assignee cross_reference title time_tracking branch milestone discussion task moved - opened closed merged duplicate + opened closed merged duplicate locked unlocked outdated ].freeze diff --git a/app/policies/issuable_policy.rb b/app/policies/issuable_policy.rb index daf6fa9e18a..f0aa16d2ecf 100644 --- a/app/policies/issuable_policy.rb +++ b/app/policies/issuable_policy.rb @@ -1,6 +1,10 @@ class IssuablePolicy < BasePolicy delegate { @subject.project } + condition(:locked, scope: :subject, score: 0) { @subject.discussion_locked? } + + condition(:is_project_member) { @user && @subject.project && @subject.project.team.member?(@user) } + desc "User is the assignee or author" condition(:assignee_or_author) do @user && @subject.assignee_or_author?(@user) @@ -12,4 +16,12 @@ class IssuablePolicy < BasePolicy enable :read_merge_request enable :update_merge_request end + + rule { locked & ~is_project_member }.policy do + prevent :create_note + prevent :update_note + prevent :admin_note + prevent :resolve_note + prevent :edit_note + end end diff --git a/app/policies/note_policy.rb b/app/policies/note_policy.rb index 20cd51cfb99..d4cb5a77e63 100644 --- a/app/policies/note_policy.rb +++ b/app/policies/note_policy.rb @@ -1,5 +1,6 @@ class NotePolicy < BasePolicy delegate { @subject.project } + delegate { @subject.noteable if @subject.noteable.lockable? } condition(:is_author) { @user && @subject.author == @user } condition(:for_merge_request, scope: :subject) { @subject.for_merge_request? } @@ -8,6 +9,7 @@ class NotePolicy < BasePolicy condition(:editable, scope: :subject) { @subject.editable? } rule { ~editable | anonymous }.prevent :edit_note + rule { is_author | admin }.enable :edit_note rule { can?(:master_access) }.enable :edit_note diff --git a/app/serializers/issue_entity.rb b/app/serializers/issue_entity.rb index 0d6feb78173..10d3ad0214b 100644 --- a/app/serializers/issue_entity.rb +++ b/app/serializers/issue_entity.rb @@ -3,6 +3,7 @@ class IssueEntity < IssuableEntity expose :branch_name expose :confidential + expose :discussion_locked expose :assignees, using: API::Entities::UserBasic expose :due_date expose :moved_to_id @@ -14,7 +15,7 @@ class IssueEntity < IssuableEntity expose :current_user do expose :can_create_note do |issue| - can?(request.current_user, :create_note, issue.project) + can?(request.current_user, :create_note, issue) end expose :can_update do |issue| diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index 12604e7eb5d..f83ece7098f 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -43,6 +43,10 @@ class IssuableBaseService < BaseService SystemNoteService.change_time_spent(issuable, issuable.project, current_user) end + def create_discussion_lock_note(issuable) + SystemNoteService.discussion_lock(issuable, current_user) + end + def filter_params(issuable) ability_name = :"admin_#{issuable.to_ability_name}" @@ -57,6 +61,7 @@ class IssuableBaseService < BaseService params.delete(:due_date) params.delete(:canonical_issue_id) params.delete(:project) + params.delete(:discussion_locked) end filter_assignee(issuable) @@ -236,6 +241,7 @@ class IssuableBaseService < BaseService handle_common_system_notes(issuable, old_labels: old_labels) end + change_discussion_lock(issuable) handle_changes( issuable, old_labels: old_labels, @@ -294,6 +300,12 @@ class IssuableBaseService < BaseService end end + def change_discussion_lock(issuable) + if issuable.previous_changes.include?('discussion_locked') + create_discussion_lock_note(issuable) + end + end + def toggle_award(issuable) award = params.delete(:emoji_award) if award diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index 1f66a2668f9..7b32e215c7f 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -591,6 +591,13 @@ module SystemNoteService create_note(NoteSummary.new(noteable, project, author, body, action: 'duplicate')) end + def discussion_lock(issuable, author) + action = issuable.discussion_locked? ? 'locked' : 'unlocked' + body = "#{action} this issue" + + create_note(NoteSummary.new(issuable, issuable.project, author, body, action: action)) + end + private def notes_for_mentioner(mentioner, noteable, notes) diff --git a/app/views/projects/_md_preview.html.haml b/app/views/projects/_md_preview.html.haml index 71424593f2e..770608eddff 100644 --- a/app/views/projects/_md_preview.html.haml +++ b/app/views/projects/_md_preview.html.haml @@ -1,5 +1,12 @@ - referenced_users = local_assigns.fetch(:referenced_users, nil) +- if defined?(@merge_request) && @merge_request.discussion_locked? + .issuable-note-warning + = icon('lock', class: 'icon') + %span + = _('This merge request is locked.') + = _('Only project members can comment.') + .md-area .md-header %ul.nav-links.clearfix diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index fbaf88356bf..b9fec8af4d7 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -27,7 +27,9 @@ .issuable-meta - if @issue.confidential - = icon('eye-slash', class: 'is-confidential') + = icon('eye-slash', class: 'issuable-warning-icon') + - if @issue.discussion_locked? + = icon('lock', class: 'issuable-warning-icon') = issuable_meta(@issue, @project, "Issue") .issuable-actions.js-issuable-actions diff --git a/app/views/projects/merge_requests/_mr_title.html.haml b/app/views/projects/merge_requests/_mr_title.html.haml index 9ff85c2ee4c..cb723fe6a18 100644 --- a/app/views/projects/merge_requests/_mr_title.html.haml +++ b/app/views/projects/merge_requests/_mr_title.html.haml @@ -15,6 +15,8 @@ = icon('angle-double-left') .issuable-meta + - if @merge_request.discussion_locked? + = icon('lock', class: 'issuable-warning-icon') = issuable_meta(@merge_request, @project, "Merge request") .issuable-actions.js-issuable-actions diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml index d3742f3e4be..d88e3d794d3 100644 --- a/app/views/projects/merge_requests/show.html.haml +++ b/app/views/projects/merge_requests/show.html.haml @@ -83,7 +83,7 @@ #pipelines.pipelines.tab-pane - if @pipelines.any? = render 'projects/commit/pipelines_list', disable_initialization: true, endpoint: pipelines_project_merge_request_path(@project, @merge_request) - #diffs.diffs.tab-pane + #diffs.diffs.tab-pane{ data: { "is-locked" => @merge_request.discussion_locked? } } -# This tab is always loaded via AJAX .mr-loading-status diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 674f13ddb23..7b7411b1e23 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -119,6 +119,10 @@ %script#js-confidential-issue-data{ type: "application/json" }= { is_confidential: @issue.confidential, is_editable: can_edit_issuable }.to_json.html_safe #js-confidential-entry-point + - if issuable.has_attribute?(:discussion_locked) + %script#js-lock-issue-data{ type: "application/json" }= { is_locked: issuable.discussion_locked?, is_editable: can_edit_issuable }.to_json.html_safe + #js-lock-entry-point + = render "shared/issuable/participants", participants: issuable.participants(current_user) - if current_user - subscribed = issuable.subscribed?(current_user, @project) diff --git a/app/views/shared/notes/_notes_with_form.html.haml b/app/views/shared/notes/_notes_with_form.html.haml index e3e86709b8f..c6e18108c7a 100644 --- a/app/views/shared/notes/_notes_with_form.html.haml +++ b/app/views/shared/notes/_notes_with_form.html.haml @@ -1,3 +1,6 @@ +- issuable = @issue || @merge_request +- discussion_locked = issuable&.discussion_locked? + %ul#notes-list.notes.main-notes-list.timeline = render "shared/notes/notes" @@ -21,5 +24,14 @@ or = link_to "sign in", new_session_path(:user, redirect_to_referer: 'yes'), class: 'js-sign-in-link' to comment - +- elsif discussion_locked + .disabled-comment.text-center.prepend-top-default + %span.issuable-note-warning + %span.icon= sprite_icon('lock', size: 14) + %span + This + = issuable.class.to_s.titleize.downcase + is locked. Only + %b project members + can comment. %script.js-notes-data{ type: "application/json" }= initial_notes_data(autocomplete).to_json.html_safe |