From ef4c776bd39a2a7d28ff1a9cbb683620014d16f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Soriano=20S=C3=A1nchez?= Date: Sat, 24 Feb 2018 08:57:36 +0000 Subject: Bug.md: Use html comments instead of asking to remove the notice The issue template for bugs has a notice at the start that mentions to search for duplicates before creating a new issue. Then the reporter is asked to remove the notice once read, presumptively to avoid noise in the actual issue. Instead of that, we can make that part of the issue template an html comment, so it won't be rendered in the final result. This avoids the tedious task of removing that part of the notice. Closes https://gitlab.com/gitlab-org/gitlab-ce/issues/43580 --- .gitlab/issue_templates/Bug.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.gitlab/issue_templates/Bug.md b/.gitlab/issue_templates/Bug.md index aec734870d6..3e58d2a867e 100644 --- a/.gitlab/issue_templates/Bug.md +++ b/.gitlab/issue_templates/Bug.md @@ -1,3 +1,4 @@ + ### Summary -- cgit v1.2.1 From 514f22c4752b6f7d71ec2023168f08ea9b052048 Mon Sep 17 00:00:00 2001 From: George Tsiolis Date: Tue, 6 Feb 2018 18:30:35 +0200 Subject: Move Assignees vue component --- .../javascripts/boards/components/board_sidebar.js | 2 +- .../sidebar/components/assignees/assignees.js | 224 -------------------- .../sidebar/components/assignees/assignees.vue | 231 +++++++++++++++++++++ .../components/assignees/sidebar_assignees.js | 6 +- .../refactor-move-assignees-vue-component.yml | 5 + spec/javascripts/sidebar/assignees_spec.js | 2 +- 6 files changed, 241 insertions(+), 229 deletions(-) delete mode 100644 app/assets/javascripts/sidebar/components/assignees/assignees.js create mode 100644 app/assets/javascripts/sidebar/components/assignees/assignees.vue create mode 100644 changelogs/unreleased/refactor-move-assignees-vue-component.yml diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js index 983429550f0..8374af523de 100644 --- a/app/assets/javascripts/boards/components/board_sidebar.js +++ b/app/assets/javascripts/boards/components/board_sidebar.js @@ -5,7 +5,7 @@ import Flash from '../../flash'; import Sidebar from '../../right_sidebar'; import eventHub from '../../sidebar/event_hub'; import assigneeTitle from '../../sidebar/components/assignees/assignee_title'; -import assignees from '../../sidebar/components/assignees/assignees'; +import assignees from '../../sidebar/components/assignees/assignees.vue'; import DueDateSelectors from '../../due_date_select'; import './sidebar/remove_issue'; import IssuableContext from '../../issuable_context'; diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees.js b/app/assets/javascripts/sidebar/components/assignees/assignees.js deleted file mode 100644 index 643877b9d47..00000000000 --- a/app/assets/javascripts/sidebar/components/assignees/assignees.js +++ /dev/null @@ -1,224 +0,0 @@ -export default { - name: 'Assignees', - data() { - return { - defaultRenderCount: 5, - defaultMaxCounter: 99, - showLess: true, - }; - }, - props: { - rootPath: { - type: String, - required: true, - }, - users: { - type: Array, - required: true, - }, - editable: { - type: Boolean, - required: true, - }, - }, - computed: { - firstUser() { - return this.users[0]; - }, - hasMoreThanTwoAssignees() { - return this.users.length > 2; - }, - hasMoreThanOneAssignee() { - return this.users.length > 1; - }, - hasAssignees() { - return this.users.length > 0; - }, - hasNoUsers() { - return !this.users.length; - }, - hasOneUser() { - return this.users.length === 1; - }, - renderShowMoreSection() { - return this.users.length > this.defaultRenderCount; - }, - numberOfHiddenAssignees() { - return this.users.length - this.defaultRenderCount; - }, - isHiddenAssignees() { - return this.numberOfHiddenAssignees > 0; - }, - hiddenAssigneesLabel() { - return `+ ${this.numberOfHiddenAssignees} more`; - }, - collapsedTooltipTitle() { - const maxRender = Math.min(this.defaultRenderCount, this.users.length); - const renderUsers = this.users.slice(0, maxRender); - const names = renderUsers.map(u => u.name); - - if (this.users.length > maxRender) { - names.push(`+ ${this.users.length - maxRender} more`); - } - - return names.join(', '); - }, - sidebarAvatarCounter() { - let counter = `+${this.users.length - 1}`; - - if (this.users.length > this.defaultMaxCounter) { - counter = `${this.defaultMaxCounter}+`; - } - - return counter; - }, - }, - methods: { - assignSelf() { - this.$emit('assign-self'); - }, - toggleShowLess() { - this.showLess = !this.showLess; - }, - renderAssignee(index) { - return !this.showLess || (index < this.defaultRenderCount && this.showLess); - }, - avatarUrl(user) { - return user.avatar || user.avatar_url || gon.default_avatar_url; - }, - assigneeUrl(user) { - return `${this.rootPath}${user.username}`; - }, - assigneeAlt(user) { - return `${user.name}'s avatar`; - }, - assigneeUsername(user) { - return `@${user.username}`; - }, - shouldRenderCollapsedAssignee(index) { - const firstTwo = this.users.length <= 2 && index <= 2; - - return index === 0 || firstTwo; - }, - }, - template: ` -
- -
- - - -
-
- `, -}; diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees.vue b/app/assets/javascripts/sidebar/components/assignees/assignees.vue new file mode 100644 index 00000000000..73d53dda6fd --- /dev/null +++ b/app/assets/javascripts/sidebar/components/assignees/assignees.vue @@ -0,0 +1,231 @@ + + + + diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js index 9e47039d920..8269fe1281d 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js @@ -1,6 +1,6 @@ import Flash from '../../../flash'; import AssigneeTitle from './assignee_title'; -import Assignees from './assignees'; +import Assignees from './assignees.vue'; import Store from '../../stores/sidebar_store'; import eventHub from '../../event_hub'; @@ -28,8 +28,8 @@ export default { }, }, components: { - 'assignee-title': AssigneeTitle, - assignees: Assignees, + AssigneeTitle, + Assignees, }, methods: { assignSelf() { diff --git a/changelogs/unreleased/refactor-move-assignees-vue-component.yml b/changelogs/unreleased/refactor-move-assignees-vue-component.yml new file mode 100644 index 00000000000..98cfa6b4c81 --- /dev/null +++ b/changelogs/unreleased/refactor-move-assignees-vue-component.yml @@ -0,0 +1,5 @@ +--- +title: Move Assignees vue component +merge_request: 16952 +author: George Tsiolis +type: performance diff --git a/spec/javascripts/sidebar/assignees_spec.js b/spec/javascripts/sidebar/assignees_spec.js index c9453a21189..4e4343812bd 100644 --- a/spec/javascripts/sidebar/assignees_spec.js +++ b/spec/javascripts/sidebar/assignees_spec.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import Assignee from '~/sidebar/components/assignees/assignees'; +import Assignee from '~/sidebar/components/assignees/assignees.vue'; import UsersMock from './mock_data'; import UsersMockHelper from '../helpers/user_mock_data_helper'; -- cgit v1.2.1 From 94d5dd3b7bf5fb922458cec7659754164e8182c6 Mon Sep 17 00:00:00 2001 From: Mike Greiling Date: Tue, 27 Feb 2018 16:51:12 -0600 Subject: remove merge_conflicts bundle in favor of pages directory entry point --- .../javascripts/pages/projects/merge_requests/conflicts/index.js | 1 + .../javascripts/pages/projects/merge_requests/conflicts/show/index.js | 3 --- app/views/projects/merge_requests/conflicts.html.haml | 2 -- app/views/projects/merge_requests/conflicts/show.html.haml | 2 -- config/webpack.config.js | 2 -- 5 files changed, 1 insertion(+), 9 deletions(-) delete mode 100644 app/assets/javascripts/pages/projects/merge_requests/conflicts/show/index.js diff --git a/app/assets/javascripts/pages/projects/merge_requests/conflicts/index.js b/app/assets/javascripts/pages/projects/merge_requests/conflicts/index.js index b92aef53510..233457ec61b 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/conflicts/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/conflicts/index.js @@ -1,3 +1,4 @@ import initSidebarBundle from '~/sidebar/sidebar_bundle'; +import '~/merge_conflicts/merge_conflicts_bundle'; document.addEventListener('DOMContentLoaded', initSidebarBundle); diff --git a/app/assets/javascripts/pages/projects/merge_requests/conflicts/show/index.js b/app/assets/javascripts/pages/projects/merge_requests/conflicts/show/index.js deleted file mode 100644 index b92aef53510..00000000000 --- a/app/assets/javascripts/pages/projects/merge_requests/conflicts/show/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import initSidebarBundle from '~/sidebar/sidebar_bundle'; - -document.addEventListener('DOMContentLoaded', initSidebarBundle); diff --git a/app/views/projects/merge_requests/conflicts.html.haml b/app/views/projects/merge_requests/conflicts.html.haml index 2a2e57027be..a6e2565a485 100644 --- a/app/views/projects/merge_requests/conflicts.html.haml +++ b/app/views/projects/merge_requests/conflicts.html.haml @@ -1,7 +1,5 @@ - page_title "Merge Conflicts", "#{@merge_request.title} (#{@merge_request.to_reference}", "Merge Requests" - content_for :page_specific_javascripts do - = webpack_bundle_tag('common_vue') - = webpack_bundle_tag('merge_conflicts') = page_specific_javascript_tag('lib/ace.js') = render "projects/merge_requests/mr_title" diff --git a/app/views/projects/merge_requests/conflicts/show.html.haml b/app/views/projects/merge_requests/conflicts/show.html.haml index 2a2e57027be..a6e2565a485 100644 --- a/app/views/projects/merge_requests/conflicts/show.html.haml +++ b/app/views/projects/merge_requests/conflicts/show.html.haml @@ -1,7 +1,5 @@ - page_title "Merge Conflicts", "#{@merge_request.title} (#{@merge_request.to_reference}", "Merge Requests" - content_for :page_specific_javascripts do - = webpack_bundle_tag('common_vue') - = webpack_bundle_tag('merge_conflicts') = page_specific_javascript_tag('lib/ace.js') = render "projects/merge_requests/mr_title" diff --git a/config/webpack.config.js b/config/webpack.config.js index 613ed1cad03..20d398c41ea 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -50,7 +50,6 @@ function generateEntries() { environments: './environments/environments_bundle.js', filtered_search: './filtered_search/filtered_search_bundle.js', help: './help/help.js', - merge_conflicts: './merge_conflicts/merge_conflicts_bundle.js', monitoring: './monitoring/monitoring_bundle.js', notebook_viewer: './blob/notebook_viewer.js', pdf_viewer: './blob/pdf_viewer.js', @@ -241,7 +240,6 @@ var config = { 'environments', 'filtered_search', 'groups', - 'merge_conflicts', 'monitoring', 'notebook_viewer', 'pdf_viewer', -- cgit v1.2.1 From c62fe6a293f62d37d9290074b917cc67a78b6cfd Mon Sep 17 00:00:00 2001 From: Mike Greiling Date: Tue, 27 Feb 2018 16:53:49 -0600 Subject: refactor merge conflicts bundle to reduce side effects --- app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js | 4 ++-- .../javascripts/pages/projects/merge_requests/conflicts/index.js | 7 +++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js b/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js index b4b3c15108d..66b258839ae 100644 --- a/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js +++ b/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js @@ -12,7 +12,7 @@ import './components/inline_conflict_lines'; import './components/parallel_conflict_lines'; import syntaxHighlight from '../syntax_highlight'; -$(() => { +export default function initMergeConflicts() { const INTERACTIVE_RESOLVE_MODE = 'interactive'; const conflictsEl = document.querySelector('#conflicts'); const mergeConflictsStore = gl.mergeConflicts.mergeConflictsStore; @@ -91,4 +91,4 @@ $(() => { } } }); -}); +} diff --git a/app/assets/javascripts/pages/projects/merge_requests/conflicts/index.js b/app/assets/javascripts/pages/projects/merge_requests/conflicts/index.js index 233457ec61b..28641104c58 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/conflicts/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/conflicts/index.js @@ -1,4 +1,7 @@ import initSidebarBundle from '~/sidebar/sidebar_bundle'; -import '~/merge_conflicts/merge_conflicts_bundle'; +import initMergeConflicts from '~/merge_conflicts/merge_conflicts_bundle'; -document.addEventListener('DOMContentLoaded', initSidebarBundle); +document.addEventListener('DOMContentLoaded', () => { + initSidebarBundle(); + initMergeConflicts(); +}); -- cgit v1.2.1 From 7ec5d320f33d46d47a28c3ec7d755a6d42bbd08b Mon Sep 17 00:00:00 2001 From: Mike Greiling Date: Tue, 27 Feb 2018 16:56:39 -0600 Subject: remove duplicate common and common_vue definitions --- config/webpack.config.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/config/webpack.config.js b/config/webpack.config.js index 20d398c41ea..11fe6be64f2 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -44,8 +44,6 @@ function generateEntries() { const manualEntries = { balsamiq_viewer: './blob/balsamiq_viewer.js', - common: './commons/index.js', - common_vue: './vue_shared/vue_resource_interceptor.js', cycle_analytics: './cycle_analytics/cycle_analytics_bundle.js', environments: './environments/environments_bundle.js', filtered_search: './filtered_search/filtered_search_bundle.js', -- cgit v1.2.1 From 059ab73b8eae3a546d0a19fe99ef0c52df5fac01 Mon Sep 17 00:00:00 2001 From: Fatih Acet Date: Wed, 28 Feb 2018 00:10:43 +0000 Subject: Render MR Notes with Vue with behind a cookie --- app/assets/javascripts/autosave.js | 32 ++-- app/assets/javascripts/awards_handler.js | 16 +- .../diff_notes/components/jump_to_discussion.js | 2 +- .../diff_notes/components/resolve_btn.js | 1 + .../javascripts/diff_notes/diff_notes_bundle.js | 15 +- .../javascripts/diff_notes/services/resolve.js | 5 +- app/assets/javascripts/lib/utils/common_utils.js | 21 ++- app/assets/javascripts/lib/utils/text_utility.js | 14 ++ app/assets/javascripts/merge_request_tabs.js | 4 + app/assets/javascripts/mr_notes/index.js | 41 +++++ app/assets/javascripts/notes.js | 138 ++++++++------ .../javascripts/notes/components/comment_form.vue | 77 +++++--- .../notes/components/diff_file_header.vue | 92 ++++++++++ .../notes/components/diff_with_note.vue | 96 ++++++++++ .../notes/components/discussion_counter.vue | 119 +++++++++++++ .../javascripts/notes/components/note_actions.vue | 61 +++++++ .../javascripts/notes/components/note_body.vue | 4 +- .../javascripts/notes/components/note_form.vue | 43 +++-- .../javascripts/notes/components/note_header.vue | 13 +- .../notes/components/noteable_discussion.vue | 198 ++++++++++++++++----- .../javascripts/notes/components/noteable_note.vue | 17 +- .../javascripts/notes/components/notes_app.vue | 43 ++++- app/assets/javascripts/notes/constants.js | 6 +- app/assets/javascripts/notes/index.js | 12 +- app/assets/javascripts/notes/mixins/autosave.js | 5 +- app/assets/javascripts/notes/mixins/noteable.js | 22 +++ app/assets/javascripts/notes/mixins/resolvable.js | 50 ++++++ .../javascripts/notes/services/notes_service.js | 7 + app/assets/javascripts/notes/stores/actions.js | 17 +- app/assets/javascripts/notes/stores/getters.js | 36 +++- .../javascripts/notes/stores/mutation_types.js | 1 + app/assets/javascripts/notes/stores/mutations.js | 43 ++++- app/assets/javascripts/notes/stores/utils.js | 1 - .../pages/projects/merge_requests/show/index.js | 1 - .../vue_shared/components/clipboard_button.vue | 2 +- .../vue_shared/components/issue/issue_warning.vue | 3 - .../vue_shared/components/notes/skeleton_note.vue | 24 +++ app/assets/stylesheets/pages/notes.scss | 2 +- app/controllers/concerns/issuable_actions.rb | 14 ++ app/controllers/concerns/notes_actions.rb | 14 +- app/controllers/projects/discussions_controller.rb | 38 +++- app/controllers/projects/issues_controller.rb | 14 -- app/controllers/projects/notes_controller.rb | 30 +++- app/helpers/notes_helper.rb | 31 ++++ app/models/note.rb | 1 + app/serializers/diff_file_entity.rb | 41 +++++ app/serializers/discussion_entity.rb | 38 ++++ app/serializers/merge_request_widget_entity.rb | 12 ++ app/serializers/note_entity.rb | 12 ++ app/views/projects/issues/_discussion.html.haml | 14 +- app/views/projects/issues/show.html.haml | 2 +- app/views/projects/merge_requests/show.html.haml | 47 +++-- app/views/shared/notes/_notes_with_form.html.haml | 7 +- config/routes/project.rb | 1 + config/webpack.config.js | 2 + .../projects/discussions_controller_spec.rb | 26 +++ .../controllers/projects/issues_controller_spec.rb | 2 +- .../merge_request/user_posts_notes_spec.rb | 2 +- .../api/schemas/entities/merge_request_widget.json | 5 +- spec/javascripts/autosave_spec.js | 55 ++++-- spec/javascripts/fixtures/merge_requests.rb | 42 +++++ .../notes/components/comment_form_spec.js | 23 ++- .../notes/components/diff_file_header_spec.js | 93 ++++++++++ .../notes/components/diff_with_note_spec.js | 64 +++++++ spec/javascripts/notes/components/note_app_spec.js | 5 +- .../javascripts/notes/components/note_body_spec.js | 27 ++- .../notes/components/note_header_spec.js | 32 +++- spec/javascripts/notes/mock_data.js | 5 +- spec/javascripts/notes/stores/getters_spec.js | 4 +- spec/javascripts/notes/stores/mutation_spec.js | 5 +- spec/javascripts/notes_spec.js | 11 +- spec/serializers/diff_file_entity_spec.rb | 24 +++ spec/serializers/discussion_entity_spec.rb | 36 ++++ spec/serializers/note_entity_spec.rb | 11 ++ 74 files changed, 1747 insertions(+), 327 deletions(-) create mode 100644 app/assets/javascripts/mr_notes/index.js create mode 100644 app/assets/javascripts/notes/components/diff_file_header.vue create mode 100644 app/assets/javascripts/notes/components/diff_with_note.vue create mode 100644 app/assets/javascripts/notes/components/discussion_counter.vue create mode 100644 app/assets/javascripts/notes/mixins/noteable.js create mode 100644 app/assets/javascripts/notes/mixins/resolvable.js create mode 100644 app/assets/javascripts/vue_shared/components/notes/skeleton_note.vue create mode 100644 app/serializers/diff_file_entity.rb create mode 100644 spec/javascripts/notes/components/diff_file_header_spec.js create mode 100644 spec/javascripts/notes/components/diff_with_note_spec.js create mode 100644 spec/serializers/diff_file_entity_spec.rb create mode 100644 spec/serializers/discussion_entity_spec.rb diff --git a/app/assets/javascripts/autosave.js b/app/assets/javascripts/autosave.js index 0f28bd233ac..0da872db7e5 100644 --- a/app/assets/javascripts/autosave.js +++ b/app/assets/javascripts/autosave.js @@ -3,10 +3,10 @@ import AccessorUtilities from './lib/utils/accessor'; export default class Autosave { - constructor(field, key, resource) { + constructor(field, key) { this.field = field; + this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe(); - this.resource = resource; if (key.join != null) { key = key.join('/'); } @@ -17,31 +17,27 @@ export default class Autosave { } restore() { - var text; - if (!this.isLocalStorageAvailable) return; + if (!this.field.length) return; - text = window.localStorage.getItem(this.key); + const text = window.localStorage.getItem(this.key); if ((text != null ? text.length : void 0) > 0) { this.field.val(text); } - 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); - } - } + + this.field.trigger('input'); + // 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); + field.dispatchEvent(event); } save() { - var text; - text = this.field.val(); + if (!this.field.length) return; + + const text = this.field.val(); if (this.isLocalStorageAvailable && (text != null ? text.length : void 0) > 0) { return window.localStorage.setItem(this.key, text); diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js index 9456edebccb..26e62732b33 100644 --- a/app/assets/javascripts/awards_handler.js +++ b/app/assets/javascripts/awards_handler.js @@ -2,7 +2,7 @@ import _ from 'underscore'; import Cookies from 'js-cookie'; import { __ } from './locale'; -import { isInIssuePage, updateTooltipTitle } from './lib/utils/common_utils'; +import { isInIssuePage, isInMRPage, hasVueMRDiscussionsCookie, updateTooltipTitle } from './lib/utils/common_utils'; import flash from './flash'; import axios from './lib/utils/axios_utils'; @@ -239,9 +239,9 @@ class AwardsHandler { } addAward(votesBlock, awardUrl, emoji, checkMutuality, callback) { - const isMainAwardsBlock = votesBlock.closest('.js-issue-note-awards').length; + const isMainAwardsBlock = votesBlock.closest('.js-noteable-awards').length; - if (isInIssuePage() && !isMainAwardsBlock) { + if (this.isInVueNoteablePage() && !isMainAwardsBlock) { const id = votesBlock.attr('id').replace('note_', ''); this.hideMenuElement($('.emoji-menu')); @@ -293,8 +293,16 @@ class AwardsHandler { } } + isVueMRDiscussions() { + return isInMRPage() && hasVueMRDiscussionsCookie() && !$('#diffs').is(':visible'); + } + + isInVueNoteablePage() { + return isInIssuePage() || this.isVueMRDiscussions(); + } + getVotesBlock() { - if (isInIssuePage()) { + if (this.isInVueNoteablePage()) { const $el = $('.js-add-award.is-active').closest('.note.timeline-entry'); if ($el.length) { diff --git a/app/assets/javascripts/diff_notes/components/jump_to_discussion.js b/app/assets/javascripts/diff_notes/components/jump_to_discussion.js index e77910a83d4..fadc34959e1 100644 --- a/app/assets/javascripts/diff_notes/components/jump_to_discussion.js +++ b/app/assets/javascripts/diff_notes/components/jump_to_discussion.js @@ -197,7 +197,7 @@ const JumpToDiscussion = Vue.extend({ } $.scrollTo($target, { - offset: 0 + offset: -150 }); } }, diff --git a/app/assets/javascripts/diff_notes/components/resolve_btn.js b/app/assets/javascripts/diff_notes/components/resolve_btn.js index 20ddcbfb8bd..cc9192deae3 100644 --- a/app/assets/javascripts/diff_notes/components/resolve_btn.js +++ b/app/assets/javascripts/diff_notes/components/resolve_btn.js @@ -87,6 +87,7 @@ const ResolveBtn = Vue.extend({ CommentsStore.update(this.discussionId, this.noteId, !this.isResolved, resolved_by); this.discussion.updateHeadline(data); gl.mrWidget.checkStatus(); + document.dispatchEvent(new CustomEvent('refreshVueNotes')); this.updateTooltip(); }) diff --git a/app/assets/javascripts/diff_notes/diff_notes_bundle.js b/app/assets/javascripts/diff_notes/diff_notes_bundle.js index 679057e787c..5f49609fe88 100644 --- a/app/assets/javascripts/diff_notes/diff_notes_bundle.js +++ b/app/assets/javascripts/diff_notes/diff_notes_bundle.js @@ -14,6 +14,7 @@ import './components/resolve_count'; import './components/resolve_discussion_btn'; import './components/diff_note_avatars'; import './components/new_issue_for_discussion'; +import { hasVueMRDiscussionsCookie } from '../lib/utils/common_utils'; export default () => { const projectPathHolder = document.querySelector('.merge-request') || document.querySelector('.commit-box'); @@ -67,12 +68,14 @@ export default () => { gl.diffNotesCompileComponents(); - new Vue({ - el: '#resolve-count-app', - components: { - 'resolve-count': ResolveCount - }, - }); + if (!hasVueMRDiscussionsCookie()) { + new Vue({ + el: '#resolve-count-app', + components: { + 'resolve-count': ResolveCount + }, + }); + } $(window).trigger('resize.nav'); }; diff --git a/app/assets/javascripts/diff_notes/services/resolve.js b/app/assets/javascripts/diff_notes/services/resolve.js index 96fe23640af..d16f9297de1 100644 --- a/app/assets/javascripts/diff_notes/services/resolve.js +++ b/app/assets/javascripts/diff_notes/services/resolve.js @@ -8,8 +8,8 @@ window.gl = window.gl || {}; class ResolveServiceClass { constructor(root) { - this.noteResource = Vue.resource(`${root}/notes{/noteId}/resolve`); - this.discussionResource = Vue.resource(`${root}/merge_requests{/mergeRequestId}/discussions{/discussionId}/resolve`); + this.noteResource = Vue.resource(`${root}/notes{/noteId}/resolve?html=true`); + this.discussionResource = Vue.resource(`${root}/merge_requests{/mergeRequestId}/discussions{/discussionId}/resolve?html=true`); } resolve(noteId) { @@ -45,6 +45,7 @@ class ResolveServiceClass { if (gl.mrWidget) gl.mrWidget.checkStatus(); discussion.updateHeadline(data); + document.dispatchEvent(new CustomEvent('refreshVueNotes')); }) .catch(() => new Flash('An error occurred when trying to resolve a discussion. Please try again.')); } diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index 017f3b986fd..e741789fbb6 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -1,3 +1,5 @@ +import jQuery from 'jquery'; +import Cookies from 'js-cookie'; import axios from './axios_utils'; import { getLocationHash } from './url_utility'; import { convertToCamelCase } from './text_utility'; @@ -22,13 +24,18 @@ export const getGroupSlug = () => { return null; }; -export const isInIssuePage = () => { - const page = getPagePath(1); - const action = getPagePath(2); +export const checkPageAndAction = (page, action) => { + const pagePath = getPagePath(1); + const actionPath = getPagePath(2); - return page === 'issues' && action === 'show'; + return pagePath === page && actionPath === action; }; +export const isInIssuePage = () => checkPageAndAction('issues', 'show'); +export const isInMRPage = () => checkPageAndAction('merge_requests', 'show'); +export const isInNoteablePage = () => isInIssuePage() || isInMRPage(); +export const hasVueMRDiscussionsCookie = () => Cookies.get('vue_mr_discussions'); + export const ajaxGet = url => axios.get(url, { params: { format: 'js' }, responseType: 'text', @@ -133,7 +140,11 @@ export const isMetaKey = e => e.metaKey || e.ctrlKey || e.altKey || e.shiftKey; // 3) Middle-click or Mouse Wheel Click (e.which is 2) export const isMetaClick = e => e.metaKey || e.ctrlKey || e.which === 2; -export const scrollToElement = ($el) => { +export const scrollToElement = (element) => { + let $el = element; + if (!(element instanceof jQuery)) { + $el = $(element); + } const top = $el.offset().top; const mrTabsHeight = $('.merge-request-tabs').height() || 0; const headerHeight = $('.navbar-gitlab').height() || 0; diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js index 94d03621bff..c0ce0786518 100644 --- a/app/assets/javascripts/lib/utils/text_utility.js +++ b/app/assets/javascripts/lib/utils/text_utility.js @@ -65,6 +65,20 @@ export function capitalizeFirstCharacter(text) { return `${text[0].toUpperCase()}${text.slice(1)}`; } +export function camelCase(str) { + return str.replace(/_+([a-z])/gi, ($1, $2) => $2.toUpperCase()); +} + +export function camelCaseKeys(obj = {}) { + return Object.keys(obj).reduce((acc, key) => { + const camelKey = camelCase(key); + return { + ...acc, + [camelKey]: obj[key], + }; + }, {}); +} + /** * Replaces all html tags from a string with the given replacement. * diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index 41971e92ec0..46789e324c2 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -241,6 +241,10 @@ export default class MergeRequestTabs { return newState; } + getCurrentAction() { + return this.currentAction; + } + loadCommits(source) { if (this.commitsLoaded) { return; diff --git a/app/assets/javascripts/mr_notes/index.js b/app/assets/javascripts/mr_notes/index.js new file mode 100644 index 00000000000..f4cba998fa7 --- /dev/null +++ b/app/assets/javascripts/mr_notes/index.js @@ -0,0 +1,41 @@ +import Vue from 'vue'; +import notesApp from '../notes/components/notes_app.vue'; +import discussionCounter from '../notes/components/discussion_counter.vue'; +import store from '../notes/stores'; + +document.addEventListener('DOMContentLoaded', () => { + new Vue({ // eslint-disable-line + el: '#js-vue-mr-discussions', + components: { + notesApp, + }, + data() { + const notesDataset = document.getElementById('js-vue-mr-discussions').dataset; + return { + noteableData: JSON.parse(notesDataset.noteableData), + currentUserData: JSON.parse(notesDataset.currentUserData), + notesData: JSON.parse(notesDataset.notesData), + }; + }, + render(createElement) { + return createElement('notes-app', { + props: { + noteableData: this.noteableData, + notesData: this.notesData, + userData: this.currentUserData, + }, + }); + }, + }); + + new Vue({ // eslint-disable-line + el: '#js-vue-discussion-counter', + components: { + discussionCounter, + }, + store, + render(createElement) { + return createElement('discussion-counter'); + }, + }); +}); diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index f17b432cffd..c640003d958 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -24,7 +24,7 @@ import GLForm from './gl_form'; import loadAwardsHandler from './awards_handler'; import Autosave from './autosave'; import TaskList from './task_list'; -import { isInViewport, getPagePath, scrollToElement, isMetaKey } from './lib/utils/common_utils'; +import { isInViewport, getPagePath, scrollToElement, isMetaKey, hasVueMRDiscussionsCookie } from './lib/utils/common_utils'; import imageDiffHelper from './image_diff/helpers/index'; import { localTimeAgo } from './lib/utils/datetime_utility'; @@ -44,6 +44,10 @@ export default class Notes { } } + static getInstance() { + return this.instance; + } + constructor(notes_url, note_ids, last_fetched_at, view, enableGFM = true) { this.updateTargetButtons = this.updateTargetButtons.bind(this); this.updateComment = this.updateComment.bind(this); @@ -102,67 +106,77 @@ export default class Notes { } addBinding() { + this.$wrapperEl = hasVueMRDiscussionsCookie() ? $(document).find('.diffs') : $(document); + // Edit note link - $(document).on('click', '.js-note-edit', this.showEditForm.bind(this)); - $(document).on('click', '.note-edit-cancel', this.cancelEdit); + this.$wrapperEl.on('click', '.js-note-edit', this.showEditForm.bind(this)); + this.$wrapperEl.on('click', '.note-edit-cancel', this.cancelEdit); // Reopen and close actions for Issue/MR combined with note form submit - $(document).on('click', '.js-comment-submit-button', this.postComment); - $(document).on('click', '.js-comment-save-button', this.updateComment); - $(document).on('keyup input', '.js-note-text', this.updateTargetButtons); + this.$wrapperEl.on('click', '.js-comment-submit-button', this.postComment); + this.$wrapperEl.on('click', '.js-comment-save-button', this.updateComment); + this.$wrapperEl.on('keyup input', '.js-note-text', this.updateTargetButtons); // resolve a discussion - $(document).on('click', '.js-comment-resolve-button', this.postComment); + this.$wrapperEl.on('click', '.js-comment-resolve-button', this.postComment); // remove a note (in general) - $(document).on('click', '.js-note-delete', this.removeNote); + this.$wrapperEl.on('click', '.js-note-delete', this.removeNote); // delete note attachment - $(document).on('click', '.js-note-attachment-delete', this.removeAttachment); + this.$wrapperEl.on('click', '.js-note-attachment-delete', this.removeAttachment); // reset main target form when clicking discard - $(document).on('click', '.js-note-discard', this.resetMainTargetForm); + this.$wrapperEl.on('click', '.js-note-discard', this.resetMainTargetForm); // update the file name when an attachment is selected - $(document).on('change', '.js-note-attachment-input', this.updateFormAttachment); + this.$wrapperEl.on('change', '.js-note-attachment-input', this.updateFormAttachment); // reply to diff/discussion notes - $(document).on('click', '.js-discussion-reply-button', this.onReplyToDiscussionNote); + this.$wrapperEl.on('click', '.js-discussion-reply-button', this.onReplyToDiscussionNote); // add diff note - $(document).on('click', '.js-add-diff-note-button', this.onAddDiffNote); + this.$wrapperEl.on('click', '.js-add-diff-note-button', this.onAddDiffNote); // add diff note for images - $(document).on('click', '.js-add-image-diff-note-button', this.onAddImageDiffNote); + this.$wrapperEl.on('click', '.js-add-image-diff-note-button', this.onAddImageDiffNote); // hide diff note form - $(document).on('click', '.js-close-discussion-note-form', this.cancelDiscussionForm); + this.$wrapperEl.on('click', '.js-close-discussion-note-form', this.cancelDiscussionForm); // toggle commit list - $(document).on('click', '.system-note-commit-list-toggler', this.toggleCommitList); + this.$wrapperEl.on('click', '.system-note-commit-list-toggler', this.toggleCommitList); // fetch notes when tab becomes visible - $(document).on('visibilitychange', this.visibilityChange); + this.$wrapperEl.on('visibilitychange', this.visibilityChange); // when issue status changes, we need to refresh data - $(document).on('issuable:change', this.refresh); + this.$wrapperEl.on('issuable:change', this.refresh); // ajax:events that happen on Form when actions like Reopen, Close are performed on Issues and MRs. - $(document).on('ajax:success', '.js-main-target-form', this.addNote); - $(document).on('ajax:success', '.js-discussion-note-form', this.addDiscussionNote); - $(document).on('ajax:success', '.js-main-target-form', this.resetMainTargetForm); - $(document).on('ajax:complete', '.js-main-target-form', this.reenableTargetFormSubmitButton); + this.$wrapperEl.on('ajax:success', '.js-main-target-form', this.addNote); + this.$wrapperEl.on('ajax:success', '.js-discussion-note-form', this.addDiscussionNote); + this.$wrapperEl.on('ajax:success', '.js-main-target-form', this.resetMainTargetForm); + this.$wrapperEl.on('ajax:complete', '.js-main-target-form', this.reenableTargetFormSubmitButton); // when a key is clicked on the notes - $(document).on('keydown', '.js-note-text', this.keydownNoteText); + this.$wrapperEl.on('keydown', '.js-note-text', this.keydownNoteText); // When the URL fragment/hash has changed, `#note_xxx` - return $(window).on('hashchange', this.onHashChange); + $(window).on('hashchange', this.onHashChange); + this.boundGetContent = this.getContent.bind(this); + document.addEventListener('refreshLegacyNotes', this.boundGetContent); + this.eventsBound = true; } cleanBinding() { - $(document).off('click', '.js-note-edit'); - $(document).off('click', '.note-edit-cancel'); - $(document).off('click', '.js-note-delete'); - $(document).off('click', '.js-note-attachment-delete'); - $(document).off('click', '.js-discussion-reply-button'); - $(document).off('click', '.js-add-diff-note-button'); - $(document).off('click', '.js-add-image-diff-note-button'); - $(document).off('visibilitychange'); - $(document).off('keyup input', '.js-note-text'); - $(document).off('click', '.js-note-target-reopen'); - $(document).off('click', '.js-note-target-close'); - $(document).off('click', '.js-note-discard'); - $(document).off('keydown', '.js-note-text'); - $(document).off('click', '.js-comment-resolve-button'); - $(document).off('click', '.system-note-commit-list-toggler'); - $(document).off('ajax:success', '.js-main-target-form'); - $(document).off('ajax:success', '.js-discussion-note-form'); - $(document).off('ajax:complete', '.js-main-target-form'); + if (!this.eventsBound) { + return; + } + + this.$wrapperEl.off('click', '.js-note-edit'); + this.$wrapperEl.off('click', '.note-edit-cancel'); + this.$wrapperEl.off('click', '.js-note-delete'); + this.$wrapperEl.off('click', '.js-note-attachment-delete'); + this.$wrapperEl.off('click', '.js-discussion-reply-button'); + this.$wrapperEl.off('click', '.js-add-diff-note-button'); + this.$wrapperEl.off('click', '.js-add-image-diff-note-button'); + this.$wrapperEl.off('visibilitychange'); + this.$wrapperEl.off('keyup input', '.js-note-text'); + this.$wrapperEl.off('click', '.js-note-target-reopen'); + this.$wrapperEl.off('click', '.js-note-target-close'); + this.$wrapperEl.off('click', '.js-note-discard'); + this.$wrapperEl.off('keydown', '.js-note-text'); + this.$wrapperEl.off('click', '.js-comment-resolve-button'); + this.$wrapperEl.off('click', '.system-note-commit-list-toggler'); + this.$wrapperEl.off('ajax:success', '.js-main-target-form'); + this.$wrapperEl.off('ajax:success', '.js-discussion-note-form'); + this.$wrapperEl.off('ajax:complete', '.js-main-target-form'); + document.removeEventListener('refreshLegacyNotes', this.boundGetContent); $(window).off('hashchange', this.onHashChange); } @@ -252,8 +266,10 @@ export default class Notes { if (this.refreshing) { return; } + this.refreshing = true; - axios.get(this.notes_url, { + + axios.get(`${this.notes_url}?html=true`, { headers: { 'X-Last-Fetched-At': this.last_fetched_at, }, @@ -350,7 +366,7 @@ export default class Notes { } if (!noteEntity.valid) { - if (noteEntity.errors.commands_only) { + if (noteEntity.errors && noteEntity.errors.commands_only) { if (noteEntity.commands_changes && Object.keys(noteEntity.commands_changes).length > 0) { $notesList.find('.system-note.being-posted').remove(); @@ -363,6 +379,10 @@ export default class Notes { const $note = $notesList.find(`#note_${noteEntity.id}`); if (Notes.isNewNote(noteEntity, this.note_ids)) { + if (hasVueMRDiscussionsCookie()) { + return; + } + this.note_ids.push(noteEntity.id); if ($notesList.length) { @@ -399,6 +419,8 @@ export default class Notes { this.setupNewNote($updatedNote); } } + + Notes.refreshVueNotes(); } isParallelView() { @@ -406,12 +428,11 @@ export default class Notes { } /** - * Render note in discussion area. - * - * Note: for rendering inline notes use renderDiscussionNote + * Render note in discussion area. To render inline notes use renderDiscussionNote. */ renderDiscussionNote(noteEntity, $form) { var discussionContainer, form, row, lineType, diffAvatarContainer; + if (!Notes.isNewNote(noteEntity, this.note_ids)) { return; } @@ -452,7 +473,9 @@ export default class Notes { // Init discussion on 'Discussion' page if it is merge request page const page = $('body').attr('data-page'); if ((page && page.indexOf('projects:merge_request') !== -1) || !noteEntity.diff_discussion_html) { - Notes.animateAppendNote(noteEntity.discussion_html, $('.main-notes-list')); + if (!hasVueMRDiscussionsCookie()) { + Notes.animateAppendNote(noteEntity.discussion_html, $('.main-notes-list')); + } } } else { // append new note to all matching discussions @@ -634,7 +657,6 @@ export default class Notes { var $noteEntityEl, $note_li; // Convert returned HTML to a jQuery object so we can modify it further $noteEntityEl = $(noteEntity.html); - $noteEntityEl.addClass('fade-in-full'); this.revertNoteEditForm($targetNote); $noteEntityEl.renderGFM(); // Find the note's `li` element by ID and replace it with the updated HTML @@ -730,7 +752,7 @@ export default class Notes { var selector = this.getEditFormSelector($target); var $editForm = $(selector); - $editForm.insertBefore('.notes-form'); + $editForm.insertBefore('.diffs'); $editForm.find('.js-comment-save-button').enable(); $editForm.find('.js-finish-edit-warning').hide(); } @@ -746,7 +768,8 @@ export default class Notes { } removeNoteEditForm($note) { - var form = $note.find('.current-note-edit-form'); + var form = $note.find('.diffs .current-note-edit-form'); + $note.removeClass('is-editing'); form.removeClass('current-note-edit-form'); form.find('.js-finish-edit-warning').hide(); @@ -818,6 +841,7 @@ export default class Notes { }; })(this)); + Notes.refreshVueNotes(); Notes.checkMergeRequestStatus(); return this.updateNotesCount(-1); } @@ -1157,7 +1181,7 @@ export default class Notes { this.glForm = new GLForm($editForm.find('form'), this.enableGFM); $editForm.find('form') - .attr('action', postUrl) + .attr('action', `${postUrl}?html=true`) .attr('data-remote', 'true'); $editForm.find('.js-form-target-id').val(targetId); $editForm.find('.js-form-target-type').val(targetType); @@ -1280,6 +1304,10 @@ export default class Notes { return $updatedNote; } + static refreshVueNotes() { + document.dispatchEvent(new CustomEvent('refreshVueNotes')); + } + /** * Get data from Form attributes to use for saving/submitting comment. */ @@ -1481,7 +1509,7 @@ export default class Notes { /* eslint-disable promise/catch-or-return */ // Make request to submit comment on server - axios.post(formAction, formData) + axios.post(`${formAction}?html=true`, formData) .then((res) => { const note = res.data; @@ -1546,6 +1574,8 @@ export default class Notes { if ($notesContainer.length) { $notesContainer.append(''); } + + Notes.refreshVueNotes(); } else if (isMainForm) { // Check if this was main thread comment // Show final note element on UI and perform form and action buttons cleanup this.addNote($form, note); @@ -1627,7 +1657,7 @@ export default class Notes { /* eslint-disable promise/catch-or-return */ // Make request to update comment on server - axios.post(formAction, formData) + axios.post(`${formAction}?html=true`, formData) .then(({ data }) => { // Submission successful! render final note element this.updateNote(data, $editingNote); diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index df796050e0d..b85c1a6ad72 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -2,10 +2,11 @@ import { mapActions, mapGetters } from 'vuex'; import _ from 'underscore'; import Autosize from 'autosize'; - import { __ } from '~/locale'; + import { __, sprintf } from '~/locale'; import Flash from '../../flash'; import Autosave from '../../autosave'; import TaskList from '../../task_list'; + import { capitalizeFirstCharacter, convertToCamelCase } from '../../lib/utils/text_utility'; import * as constants from '../constants'; import eventHub from '../event_hub'; import issueWarning from '../../vue_shared/components/issue/issue_warning.vue'; @@ -29,6 +30,12 @@ mixins: [ issuableStateMixin, ], + props: { + noteableType: { + type: String, + required: true, + }, + }, data() { return { note: '', @@ -43,37 +50,51 @@ 'getUserData', 'getNoteableData', 'getNotesData', - 'issueState', + 'openState', ]), + noteableDisplayName() { + return this.noteableType.replace(/_/g, ' '); + }, isLoggedIn() { return this.getUserData.id; }, commentButtonTitle() { return this.noteType === constants.COMMENT ? 'Comment' : 'Start discussion'; }, - isIssueOpen() { - return this.issueState === constants.OPENED || this.issueState === constants.REOPENED; + isOpen() { + return this.openState === constants.OPENED || this.openState === constants.REOPENED; }, canCreateNote() { return this.getNoteableData.current_user.can_create_note; }, issueActionButtonTitle() { - if (this.note.length) { - const actionText = this.isIssueOpen ? 'close' : 'reopen'; + const openOrClose = this.isOpen ? 'close' : 'reopen'; - return this.noteType === constants.COMMENT ? - `Comment & ${actionText} issue` : - `Start discussion & ${actionText} issue`; + if (this.note.length) { + return sprintf( + __('%{actionText} & %{openOrClose} %{noteable}'), + { + actionText: this.commentButtonTitle, + openOrClose, + noteable: this.noteableDisplayName, + }, + ); } - return this.isIssueOpen ? 'Close issue' : 'Reopen issue'; + return sprintf( + __('%{openOrClose} %{noteable}'), + { + openOrClose: capitalizeFirstCharacter(openOrClose), + noteable: this.noteableDisplayName, + }, + ); }, actionButtonClassNames() { return { - 'btn-reopen': !this.isIssueOpen, - 'btn-close': this.isIssueOpen, - 'js-note-target-close': this.isIssueOpen, - 'js-note-target-reopen': !this.isIssueOpen, + 'btn-reopen': !this.isOpen, + 'btn-close': this.isOpen, + 'js-note-target-close': this.isOpen, + 'js-note-target-reopen': !this.isOpen, }; }, markdownDocsPath() { @@ -138,7 +159,7 @@ flashContainer: this.$el, data: { note: { - noteable_type: constants.NOTEABLE_TYPE, + noteable_type: this.noteableType, noteable_id: this.getNoteableData.id, note: this.note, }, @@ -193,19 +214,29 @@ Please check your network connection and try again.`; this.isSubmitting = false; }, toggleIssueState() { - if (this.isIssueOpen) { + if (this.isOpen) { this.closeIssue() .then(() => this.enableButton()) .catch(() => { this.enableButton(); - Flash(__('Something went wrong while closing the issue. Please try again later')); + Flash( + sprintf( + __('Something went wrong while closing the %{issuable}. Please try again later'), + { issuable: this.noteableDisplayName }, + ), + ); }); } else { this.reopenIssue() .then(() => this.enableButton()) .catch(() => { this.enableButton(); - Flash(__('Something went wrong while reopening the issue. Please try again later')); + Flash( + sprintf( + __('Something went wrong while reopening the %{issuable}. Please try again later'), + { issuable: this.noteableDisplayName }, + ), + ); }); } }, @@ -221,7 +252,6 @@ Please check your network connection and try again.`; this.$refs.markdownField.previewMarkdown = false; } - // reset autostave this.autosave.reset(); }, setNoteType(type) { @@ -240,10 +270,11 @@ Please check your network connection and try again.`; }, initAutoSave() { if (this.isLoggedIn) { + const noteableType = capitalizeFirstCharacter(convertToCamelCase(this.noteableType)); + this.autosave = new Autosave( $(this.$refs.textarea), - ['Note', 'Issue', this.getNoteableData.id], - 'issue', + ['Note', noteableType, this.getNoteableData.id], ); } }, @@ -331,7 +362,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown" :disabled="isSubmitButtonDisabled" class="btn btn-create comment-btn js-comment-button js-comment-submit-button" type="submit"> - {{ commentButtonTitle }} + {{ __(commentButtonTitle) }} diff --git a/app/assets/javascripts/notes/components/diff_file_header.vue b/app/assets/javascripts/notes/components/diff_file_header.vue new file mode 100644 index 00000000000..fe5baa3537f --- /dev/null +++ b/app/assets/javascripts/notes/components/diff_file_header.vue @@ -0,0 +1,92 @@ + + + diff --git a/app/assets/javascripts/notes/components/diff_with_note.vue b/app/assets/javascripts/notes/components/diff_with_note.vue new file mode 100644 index 00000000000..75a32709ad5 --- /dev/null +++ b/app/assets/javascripts/notes/components/diff_with_note.vue @@ -0,0 +1,96 @@ + + + diff --git a/app/assets/javascripts/notes/components/discussion_counter.vue b/app/assets/javascripts/notes/components/discussion_counter.vue new file mode 100644 index 00000000000..0158f58b569 --- /dev/null +++ b/app/assets/javascripts/notes/components/discussion_counter.vue @@ -0,0 +1,119 @@ + + + diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue index 46ffb60aa60..c26aa6fa15d 100644 --- a/app/assets/javascripts/notes/components/note_actions.vue +++ b/app/assets/javascripts/notes/components/note_actions.vue @@ -4,6 +4,8 @@ import emojiSmile from 'icons/_emoji_smile.svg'; import emojiSmiley from 'icons/_emoji_smiley.svg'; import editSvg from 'icons/_icon_pencil.svg'; + import resolveDiscussionSvg from 'icons/_icon_resolve_discussion.svg'; + import resolvedDiscussionSvg from 'icons/_icon_status_success_solid.svg'; import ellipsisSvg from 'icons/_ellipsis_v.svg'; import loadingIcon from '~/vue_shared/components/loading_icon.vue'; import tooltip from '~/vue_shared/directives/tooltip'; @@ -42,6 +44,26 @@ type: Boolean, required: true, }, + resolvable: { + type: Boolean, + required: false, + default: false, + }, + isResolved: { + type: Boolean, + required: false, + default: false, + }, + isResolving: { + type: Boolean, + required: false, + default: false, + }, + resolvedBy: { + type: Object, + required: false, + default: () => ({}), + }, canReportAsAbuse: { type: Boolean, required: true, @@ -63,6 +85,15 @@ currentUserId() { return this.getUserDataByProp('id'); }, + resolveButtonTitle() { + let title = 'Mark as resolved'; + + if (this.resolvedBy) { + title = `Resolved by ${this.resolvedBy.name}`; + } + + return title; + }, }, created() { this.emojiSmiling = emojiSmiling; @@ -70,6 +101,8 @@ this.emojiSmiley = emojiSmiley; this.editSvg = editSvg; this.ellipsisSvg = ellipsisSvg; + this.resolveDiscussionSvg = resolveDiscussionSvg; + this.resolvedDiscussionSvg = resolvedDiscussionSvg; }, methods: { onEdit() { @@ -78,6 +111,9 @@ onDelete() { this.$emit('handleDelete'); }, + onResolve() { + this.$emit('handleResolve'); + }, }, }; @@ -89,6 +125,31 @@ class="note-role user-access-role"> {{ accessLevel }} +
+ +
diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue index 2d7cd30115d..ca12df9db64 100644 --- a/app/assets/javascripts/notes/components/note_body.vue +++ b/app/assets/javascripts/notes/components/note_body.vue @@ -41,7 +41,7 @@ this.initTaskList(); if (this.isEditing) { - this.initAutoSave(); + this.initAutoSave(this.note.noteable_type); } }, updated() { @@ -50,7 +50,7 @@ if (this.isEditing) { if (!this.autosave) { - this.initAutoSave(); + this.initAutoSave(this.note.noteable_type); } else { this.setAutoSave(); } diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index d382a9bb642..1a13fdbeb7c 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -1,9 +1,10 @@ diff --git a/app/assets/javascripts/notes/constants.js b/app/assets/javascripts/notes/constants.js index a6961063c01..f4f407ffd8a 100644 --- a/app/assets/javascripts/notes/constants.js +++ b/app/assets/javascripts/notes/constants.js @@ -1,4 +1,5 @@ export const DISCUSSION_NOTE = 'DiscussionNote'; +export const DIFF_NOTE = 'DiffNote'; export const DISCUSSION = 'discussion'; export const NOTE = 'note'; export const SYSTEM_NOTE = 'systemNote'; @@ -8,4 +9,7 @@ export const REOPENED = 'reopened'; export const CLOSED = 'closed'; export const EMOJI_THUMBSUP = 'thumbsup'; export const EMOJI_THUMBSDOWN = 'thumbsdown'; -export const NOTEABLE_TYPE = 'Issue'; +export const ISSUE_NOTEABLE_TYPE = 'issue'; +export const MERGE_REQUEST_NOTEABLE_TYPE = 'merge_request'; +export const UNRESOLVE_NOTE_METHOD_NAME = 'delete'; +export const RESOLVE_NOTE_METHOD_NAME = 'post'; diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js index 48e7cfddb63..545bf2c99a7 100644 --- a/app/assets/javascripts/notes/index.js +++ b/app/assets/javascripts/notes/index.js @@ -20,17 +20,7 @@ document.addEventListener('DOMContentLoaded', () => new Vue({ return { noteableData: JSON.parse(notesDataset.noteableData), currentUserData, - notesData: { - lastFetchedAt: notesDataset.lastFetchedAt, - discussionsPath: notesDataset.discussionsPath, - newSessionPath: notesDataset.newSessionPath, - registerPath: notesDataset.registerPath, - notesPath: notesDataset.notesPath, - markdownDocsPath: notesDataset.markdownDocsPath, - quickActionsDocsPath: notesDataset.quickActionsDocsPath, - closeIssuePath: notesDataset.closeIssuePath, - reopenIssuePath: notesDataset.reopenIssuePath, - }, + notesData: JSON.parse(notesDataset.notesData), }; }, render(createElement) { diff --git a/app/assets/javascripts/notes/mixins/autosave.js b/app/assets/javascripts/notes/mixins/autosave.js index a008171beda..a3d897f2f12 100644 --- a/app/assets/javascripts/notes/mixins/autosave.js +++ b/app/assets/javascripts/notes/mixins/autosave.js @@ -1,9 +1,10 @@ import Autosave from '../../autosave'; +import { capitalizeFirstCharacter } from '../../lib/utils/text_utility'; export default { methods: { - initAutoSave() { - this.autosave = new Autosave($(this.$refs.noteForm.$refs.textarea), ['Note', 'Issue', this.note.id], 'issue'); + initAutoSave(noteableType) { + this.autosave = new Autosave($(this.$refs.noteForm.$refs.textarea), ['Note', capitalizeFirstCharacter(noteableType), this.note.id]); }, resetAutoSave() { this.autosave.reset(); diff --git a/app/assets/javascripts/notes/mixins/noteable.js b/app/assets/javascripts/notes/mixins/noteable.js new file mode 100644 index 00000000000..0da4ff49f08 --- /dev/null +++ b/app/assets/javascripts/notes/mixins/noteable.js @@ -0,0 +1,22 @@ +import * as constants from '../constants'; + +export default { + props: { + note: { + type: Object, + required: true, + }, + }, + computed: { + noteableType() { + switch (this.note.noteable_type) { + case 'MergeRequest': + return constants.MERGE_REQUEST_NOTEABLE_TYPE; + case 'Issue': + return constants.ISSUE_NOTEABLE_TYPE; + default: + return ''; + } + }, + }, +}; diff --git a/app/assets/javascripts/notes/mixins/resolvable.js b/app/assets/javascripts/notes/mixins/resolvable.js new file mode 100644 index 00000000000..ab1ae115e52 --- /dev/null +++ b/app/assets/javascripts/notes/mixins/resolvable.js @@ -0,0 +1,50 @@ +import Flash from '~/flash'; +import { __ } from '~/locale'; + +export default { + props: { + note: { + type: Object, + required: true, + }, + }, + computed: { + discussionResolved() { + const { notes, resolved } = this.note; + + if (notes) { // Decide resolved state using store. Only valid for discussions. + return notes.every(note => note.resolved && !note.system); + } + + return resolved; + }, + resolveButtonTitle() { + if (this.updatedNoteBody) { + if (this.discussionResolved) { + return __('Comment and unresolve discussion'); + } + + return __('Comment and resolve discussion'); + } + return this.discussionResolved ? __('Unresolve discussion') : __('Resolve discussion'); + }, + }, + methods: { + resolveHandler(resolvedState = false) { + this.isResolving = true; + const endpoint = this.note.resolve_path || `${this.note.path}/resolve`; + const isResolved = this.discussionResolved || resolvedState; + const discussion = this.resolveAsThread; + + this.toggleResolveNote({ endpoint, isResolved, discussion }) + .then(() => { + this.isResolving = false; + }) + .catch(() => { + this.isResolving = false; + const msg = __('Something went wrong while resolving this discussion. Please try again.'); + Flash(msg, 'alert', this.$el); + }); + }, + }, +}; diff --git a/app/assets/javascripts/notes/services/notes_service.js b/app/assets/javascripts/notes/services/notes_service.js index b8e7ffc8c46..4766351dfc5 100644 --- a/app/assets/javascripts/notes/services/notes_service.js +++ b/app/assets/javascripts/notes/services/notes_service.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import VueResource from 'vue-resource'; +import * as constants from '../constants'; Vue.use(VueResource); @@ -19,6 +20,12 @@ export default { createNewNote(endpoint, data) { return Vue.http.post(endpoint, data, { emulateJSON: true }); }, + toggleResolveNote(endpoint, isResolved) { + const { RESOLVE_NOTE_METHOD_NAME, UNRESOLVE_NOTE_METHOD_NAME } = constants; + const method = isResolved ? UNRESOLVE_NOTE_METHOD_NAME : RESOLVE_NOTE_METHOD_NAME; + + return Vue.http[method](endpoint); + }, poll(data = {}) { const { endpoint, lastFetchedAt } = data; const options = { diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index 4c846d69b86..42fc2a131b8 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -61,8 +61,17 @@ export const createNewNote = ({ commit }, { endpoint, data }) => service export const removePlaceholderNotes = ({ commit }) => commit(types.REMOVE_PLACEHOLDER_NOTES); +export const toggleResolveNote = ({ commit }, { endpoint, isResolved, discussion }) => service + .toggleResolveNote(endpoint, isResolved) + .then(res => res.json()) + .then((res) => { + const mutationType = discussion ? types.UPDATE_DISCUSSION : types.UPDATE_NOTE; + + commit(mutationType, res); + }); + export const closeIssue = ({ commit, dispatch, state }) => service - .toggleIssueState(state.notesData.closeIssuePath) + .toggleIssueState(state.notesData.closePath) .then(res => res.json()) .then((data) => { commit(types.CLOSE_ISSUE); @@ -70,7 +79,7 @@ export const closeIssue = ({ commit, dispatch, state }) => service }); export const reopenIssue = ({ commit, dispatch, state }) => service - .toggleIssueState(state.notesData.reopenIssuePath) + .toggleIssueState(state.notesData.reopenPath) .then(res => res.json()) .then((data) => { commit(types.REOPEN_ISSUE); @@ -80,7 +89,7 @@ export const reopenIssue = ({ commit, dispatch, state }) => service export const emitStateChangedEvent = ({ commit, getters }, data) => { const event = new CustomEvent('issuable_vue_app:change', { detail: { data, - isClosed: getters.issueState === constants.CLOSED, + isClosed: getters.openState === constants.CLOSED, } }); document.dispatchEvent(event); @@ -174,7 +183,7 @@ const pollSuccessCallBack = (resp, commit, state, getters) => { resp.notes.forEach((note) => { if (notesById[note.id]) { commit(types.UPDATE_NOTE, note); - } else if (note.type === constants.DISCUSSION_NOTE) { + } else if (note.type === constants.DISCUSSION_NOTE || note.type === constants.DIFF_NOTE) { const discussion = utils.findNoteObjectById(state.notes, note.discussion_id); if (discussion) { diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js index 82024104d73..e6180101c58 100644 --- a/app/assets/javascripts/notes/stores/getters.js +++ b/app/assets/javascripts/notes/stores/getters.js @@ -8,7 +8,7 @@ export const getNotesDataByProp = state => prop => state.notesData[prop]; export const getNoteableData = state => state.noteableData; export const getNoteableDataByProp = state => prop => state.noteableData[prop]; -export const issueState = state => state.noteableData.state; +export const openState = state => state.noteableData.state; export const getUserData = state => state.userData || {}; export const getUserDataByProp = state => prop => state.userData && state.userData[prop]; @@ -30,3 +30,37 @@ export const getCurrentUserLastNote = state => _.flatten( export const getDiscussionLastNote = state => discussion => reverseNotes(discussion.notes) .find(el => isLastNote(el, state)); + +export const discussionCount = (state) => { + const discussions = state.notes.filter(n => !n.individual_note); + + return discussions.length; +}; + +export const unresolvedDiscussions = (state, getters) => { + const resolvedMap = getters.resolvedDiscussionsById; + + return state.notes.filter(n => !n.individual_note && !resolvedMap[n.id]); +}; + +export const resolvedDiscussionsById = (state) => { + const map = {}; + + state.notes.forEach((n) => { + if (n.notes) { + const resolved = n.notes.every(note => note.resolved && !note.system); + + if (resolved) { + map[n.id] = n; + } + } + }); + + return map; +}; + +export const resolvedDiscussionCount = (state, getters) => { + const resolvedMap = getters.resolvedDiscussionsById; + + return Object.keys(resolvedMap).length; +}; diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js index 6d7c3bbae0f..da1b5a9e51a 100644 --- a/app/assets/javascripts/notes/stores/mutation_types.js +++ b/app/assets/javascripts/notes/stores/mutation_types.js @@ -12,6 +12,7 @@ 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'; +export const UPDATE_DISCUSSION = 'UPDATE_DISCUSSION'; // Issue export const CLOSE_ISSUE = 'CLOSE_ISSUE'; diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js index b3f66578c9a..963b40be3fd 100644 --- a/app/assets/javascripts/notes/stores/mutations.js +++ b/app/assets/javascripts/notes/stores/mutations.js @@ -1,22 +1,32 @@ import * as utils from './utils'; import * as types from './mutation_types'; import * as constants from '../constants'; +import { isInMRPage } from '../../lib/utils/common_utils'; export default { [types.ADD_NEW_NOTE](state, note) { const { discussion_id, type } = note; const [exists] = state.notes.filter(n => n.id === note.discussion_id); + const isDiscussion = (type === constants.DISCUSSION_NOTE); if (!exists) { const noteData = { expanded: true, id: discussion_id, - individual_note: !(type === constants.DISCUSSION_NOTE), + individual_note: !isDiscussion, notes: [note], reply_id: discussion_id, }; + if (isDiscussion && isInMRPage()) { + noteData.resolvable = note.resolvable; + noteData.resolved = false; + noteData.resolve_path = note.resolve_path; + noteData.resolve_with_issue_path = note.resolve_with_issue_path; + } + state.notes.push(noteData); + document.dispatchEvent(new CustomEvent('refreshLegacyNotes')); } }, @@ -25,6 +35,7 @@ export default { if (noteObj) { noteObj.notes.push(note); + document.dispatchEvent(new CustomEvent('refreshLegacyNotes')); } }, @@ -41,6 +52,8 @@ export default { state.notes.splice(state.notes.indexOf(noteObj), 1); } } + + document.dispatchEvent(new CustomEvent('refreshLegacyNotes')); }, [types.REMOVE_PLACEHOLDER_NOTES](state) { @@ -77,15 +90,19 @@ export default { const notes = []; notesData.forEach((note) => { + const nn = Object.assign({}, 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); + const oldNote = utils.findNoteObjectById(state.notes, note.id); + nn.expanded = oldNote ? oldNote.expanded : note.expanded; + + notes.push(nn); } }); @@ -134,6 +151,8 @@ export default { user: { id, name, username }, }); } + + document.dispatchEvent(new CustomEvent('refreshLegacyNotes')); }, [types.TOGGLE_DISCUSSION](state, { discussionId }) { @@ -151,6 +170,24 @@ export default { const comment = utils.findNoteObjectById(noteObj.notes, note.id); noteObj.notes.splice(noteObj.notes.indexOf(comment), 1, note); } + + // document.dispatchEvent(new CustomEvent('refreshLegacyNotes')); + }, + + [types.UPDATE_DISCUSSION](state, noteData) { + const note = noteData; + let index = 0; + + state.notes.forEach((n, i) => { + if (n.id === note.id) { + index = i; + } + }); + + note.expanded = true; // override expand flag to prevent collapse + state.notes.splice(index, 1, note); + + document.dispatchEvent(new CustomEvent('refreshLegacyNotes')); }, [types.CLOSE_ISSUE](state) { diff --git a/app/assets/javascripts/notes/stores/utils.js b/app/assets/javascripts/notes/stores/utils.js index 6074115e855..275263a2aaa 100644 --- a/app/assets/javascripts/notes/stores/utils.js +++ b/app/assets/javascripts/notes/stores/utils.js @@ -28,4 +28,3 @@ export const getQuickActionText = (note) => { 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/pages/projects/merge_requests/show/index.js b/app/assets/javascripts/pages/projects/merge_requests/show/index.js index b7b6b0b5364..b48ba1ef95e 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/show/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/show/index.js @@ -22,7 +22,6 @@ document.addEventListener('DOMContentLoaded', () => { initPipelines(); const mrShowNode = document.querySelector('.merge-request'); - window.mergeRequest = new MergeRequest({ action: mrShowNode.dataset.mrAction, }); diff --git a/app/assets/javascripts/vue_shared/components/clipboard_button.vue b/app/assets/javascripts/vue_shared/components/clipboard_button.vue index 31d9b9d9c48..e855ec3c098 100644 --- a/app/assets/javascripts/vue_shared/components/clipboard_button.vue +++ b/app/assets/javascripts/vue_shared/components/clipboard_button.vue @@ -1,8 +1,8 @@ diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 26e6e8688b6..3c565837383 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -723,7 +723,7 @@ ul.notes { .line-resolve-all { vertical-align: middle; display: inline-block; - padding: 5px 10px 6px; + padding: 6px 10px; background-color: $gray-light; border: 1px solid $border-color; border-radius: $border-radius-default; diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb index 337957c366d..a21e658fda1 100644 --- a/app/controllers/concerns/issuable_actions.rb +++ b/app/controllers/concerns/issuable_actions.rb @@ -77,6 +77,20 @@ module IssuableActions render json: { notice: "#{quantity} #{resource_name.pluralize(quantity)} updated" } end + def discussions + notes = issuable.notes + .inc_relations_for_view + .includes(:noteable) + .fresh + + notes = prepare_notes_for_rendering(notes) + notes = notes.reject { |n| n.cross_reference_not_visible_for?(current_user) } + + discussions = Discussion.build_collection(notes, issuable) + + render json: DiscussionSerializer.new(project: project, noteable: issuable, current_user: current_user).represent(discussions, context: self) + end + private def recaptcha_check_if_spammable(should_redirect = true, &block) diff --git a/app/controllers/concerns/notes_actions.rb b/app/controllers/concerns/notes_actions.rb index e82a5650935..03ed5b5310b 100644 --- a/app/controllers/concerns/notes_actions.rb +++ b/app/controllers/concerns/notes_actions.rb @@ -22,7 +22,7 @@ module NotesActions notes = notes.reject { |n| n.cross_reference_not_visible_for?(current_user) } notes_json[:notes] = - if noteable.discussions_rendered_on_frontend? + if use_note_serializer? note_serializer.represent(notes) else notes.map { |note| note_json(note) } @@ -95,7 +95,7 @@ module NotesActions if note.persisted? attrs[:valid] = true - if noteable.discussions_rendered_on_frontend? + if use_note_serializer? attrs.merge!(note_serializer.represent(note)) else attrs.merge!( @@ -233,4 +233,14 @@ module NotesActions the_project end end + + def use_note_serializer? + return false if params['html'] + + if noteable.is_a?(MergeRequest) + cookies[:vue_mr_discussions] == 'true' + else + noteable.discussions_rendered_on_frontend? + end + end end diff --git a/app/controllers/projects/discussions_controller.rb b/app/controllers/projects/discussions_controller.rb index 2e6ab7903b8..ee507009e50 100644 --- a/app/controllers/projects/discussions_controller.rb +++ b/app/controllers/projects/discussions_controller.rb @@ -1,4 +1,7 @@ class Projects::DiscussionsController < Projects::ApplicationController + include NotesHelper + include RendersNotes + before_action :check_merge_requests_available! before_action :merge_request before_action :discussion @@ -7,22 +10,45 @@ class Projects::DiscussionsController < Projects::ApplicationController def resolve Discussions::ResolveService.new(project, current_user, merge_request: merge_request).execute(discussion) - render json: { - resolved_by: discussion.resolved_by.try(:name), - discussion_headline_html: view_to_html_string('discussions/_headline', discussion: discussion) - } + render_discussion end def unresolve discussion.unresolve! + render_discussion + end + + private + + def render_discussion + if serialize_notes? + # TODO - It is not needed to serialize notes when resolving + # or unresolving discussions. We should remove this behavior + # passing a parameter to DiscussionEntity to return an empty array + # for notes. + # Check issue: https://gitlab.com/gitlab-org/gitlab-ce/issues/42853 + prepare_notes_for_rendering(discussion.notes, merge_request) + render_json_with_discussions_serializer + else + render_json_with_html + end + end + + def render_json_with_discussions_serializer + render json: + DiscussionSerializer.new(project: project, noteable: discussion.noteable, current_user: current_user) + .represent(discussion, context: self) + end + + # Legacy method used to render discussions notes when not using Vue on views. + def render_json_with_html render json: { + resolved_by: discussion.resolved_by.try(:name), discussion_headline_html: view_to_html_string('discussions/_headline', discussion: discussion) } end - private - def merge_request @merge_request ||= MergeRequestsFinder.new(current_user, project_id: @project.id).find_by!(iid: params[:merge_request_id]) end diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 73806454525..b14939c4216 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -60,20 +60,6 @@ class Projects::IssuesController < Projects::ApplicationController respond_with(@issue) end - def discussions - notes = @issue.notes - .inc_relations_for_view - .includes(:noteable) - .fresh - - notes = prepare_notes_for_rendering(notes) - notes = notes.reject { |n| n.cross_reference_not_visible_for?(current_user) } - - 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], diff --git a/app/controllers/projects/notes_controller.rb b/app/controllers/projects/notes_controller.rb index 4f8978c93c3..dd41b9648e8 100644 --- a/app/controllers/projects/notes_controller.rb +++ b/app/controllers/projects/notes_controller.rb @@ -1,5 +1,6 @@ class Projects::NotesController < Projects::ApplicationController include NotesActions + include NotesHelper include ToggleAwardEmoji before_action :whitelist_query_limiting, only: [:create] @@ -38,10 +39,14 @@ class Projects::NotesController < Projects::ApplicationController discussion = note.discussion - render json: { - resolved_by: note.resolved_by.try(:name), - discussion_headline_html: (view_to_html_string('discussions/_headline', discussion: discussion) if discussion) - } + if serialize_notes? + render_json_with_notes_serializer + else + render json: { + resolved_by: note.resolved_by.try(:name), + discussion_headline_html: (view_to_html_string('discussions/_headline', discussion: discussion) if discussion) + } + end end def unresolve @@ -51,16 +56,27 @@ class Projects::NotesController < Projects::ApplicationController discussion = note.discussion - render json: { - discussion_headline_html: (view_to_html_string('discussions/_headline', discussion: discussion) if discussion) - } + if serialize_notes? + render_json_with_notes_serializer + else + render json: { + discussion_headline_html: (view_to_html_string('discussions/_headline', discussion: discussion) if discussion) + } + end end private + def render_json_with_notes_serializer + Notes::RenderService.new(current_user).execute([note], project) + + render json: note_serializer.represent(note) + end + def note @note ||= @project.notes.find(params[:id]) end + alias_method :awardable, :note def finder_params diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb index c219aa3d6a9..e86e43b5ebf 100644 --- a/app/helpers/notes_helper.rb +++ b/app/helpers/notes_helper.rb @@ -151,7 +151,38 @@ module NotesHelper } end + def notes_data(issuable) + discussions_path = + if issuable.is_a?(Issue) + discussions_project_issue_path(@project, issuable, format: :json) + else + discussions_project_merge_request_path(@project, issuable, format: :json) + end + + { + discussionsPath: discussions_path, + registerPath: new_session_path(:user, redirect_to_referer: 'yes', anchor: 'register-pane'), + newSessionPath: new_session_path(:user, redirect_to_referer: 'yes'), + markdownDocsPath: help_page_path('user/markdown'), + quickActionsDocsPath: help_page_path('user/project/quick_actions'), + closePath: close_issuable_path(issuable), + reopenPath: reopen_issuable_path(issuable), + notesPath: notes_url, + totalNotes: issuable.discussions.length, + lastFetchedAt: Time.now + + }.to_json + end + def discussion_resolved_intro(discussion) discussion.resolved_by_push? ? 'Automatically resolved' : 'Resolved' end + + def has_vue_discussions_cookie? + cookies[:vue_mr_discussions] == 'true' + end + + def serialize_notes? + has_vue_discussions_cookie? && !params['html'] + end end diff --git a/app/models/note.rb b/app/models/note.rb index cac60845a49..d7a67ec277c 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -133,6 +133,7 @@ class Note < ActiveRecord::Base def find_discussion(discussion_id) notes = where(discussion_id: discussion_id).fresh.to_a + return if notes.empty? Discussion.build(notes) diff --git a/app/serializers/diff_file_entity.rb b/app/serializers/diff_file_entity.rb new file mode 100644 index 00000000000..6e68d275047 --- /dev/null +++ b/app/serializers/diff_file_entity.rb @@ -0,0 +1,41 @@ +class DiffFileEntity < Grape::Entity + include DiffHelper + include SubmoduleHelper + include BlobHelper + include IconsHelper + include ActionView::Helpers::TagHelper + + expose :submodule?, as: :submodule + + expose :submodule_link do |diff_file| + submodule_links(diff_file.blob, diff_file.content_sha, diff_file.repository).first + end + + expose :blob_path do |diff_file| + diff_file.blob.path + end + + expose :blob_icon do |diff_file| + blob_icon(diff_file.b_mode, diff_file.file_path) + end + + expose :file_path + expose :deleted_file?, as: :deleted_file + expose :renamed_file?, as: :renamed_file + expose :old_path + expose :new_path + expose :mode_changed?, as: :mode_changed + expose :a_mode + expose :b_mode + expose :text?, as: :text + + expose :old_path_html do |diff_file| + old_path = mark_inline_diffs(diff_file.old_path, diff_file.new_path) + old_path + end + + expose :new_path_html do |diff_file| + _, new_path = mark_inline_diffs(diff_file.old_path, diff_file.new_path) + new_path + end +end diff --git a/app/serializers/discussion_entity.rb b/app/serializers/discussion_entity.rb index 0a92e3f8167..bbbcf6a97c1 100644 --- a/app/serializers/discussion_entity.rb +++ b/app/serializers/discussion_entity.rb @@ -7,4 +7,42 @@ class DiscussionEntity < Grape::Entity expose :notes, using: NoteEntity expose :individual_note?, as: :individual_note + expose :resolvable?, as: :resolvable + expose :resolved?, as: :resolved + expose :resolve_path, if: -> (d, _) { d.resolvable? } do |discussion| + resolve_project_merge_request_discussion_path(discussion.project, discussion.noteable, discussion.id) + end + expose :resolve_with_issue_path do |discussion| + new_project_issue_path(discussion.project, merge_request_to_resolve_discussions_of: discussion.noteable.iid, discussion_to_resolve: discussion.id) + end + + expose :diff_file, using: DiffFileEntity, if: -> (d, _) { defined? d.diff_file } + + expose :diff_discussion?, as: :diff_discussion + + expose :truncated_diff_lines, if: -> (d, _) { (defined? d.diff_file) && d.diff_file.text? } do |discussion| + options[:context].render_to_string( + partial: "projects/diffs/line", + collection: discussion.truncated_diff_lines, + as: :line, + locals: { diff_file: discussion.diff_file, + discussion_expanded: true, + plain: true }, + layout: false, + formats: [:html] + ) + end + + expose :image_diff_html, if: -> (d, _) { defined? d.diff_file } do |discussion| + diff_file = discussion.diff_file + partial = diff_file.new_file? || diff_file.deleted_file? ? 'single_image_diff' : 'replaced_image_diff' + options[:context].render_to_string( + partial: "projects/diffs/#{partial}", + locals: { diff_file: diff_file, + position: discussion.position.to_json, + click_to_comment: false }, + layout: false, + formats: [:html] + ) + end end diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb index fbfe480503b..4e8ef320af2 100644 --- a/app/serializers/merge_request_widget_entity.rb +++ b/app/serializers/merge_request_widget_entity.rb @@ -115,6 +115,14 @@ class MergeRequestWidgetEntity < IssuableEntity expose :can_cherry_pick_on_current_merge_request do |merge_request| presenter(merge_request).can_cherry_pick_on_current_merge_request? end + + 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 # Paths @@ -189,6 +197,10 @@ class MergeRequestWidgetEntity < IssuableEntity end end + expose :create_note_path do |merge_request| + project_notes_path(merge_request.project, target_type: 'merge_request', target_id: merge_request.id) + end + expose :commit_change_content_path do |merge_request| commit_change_content_project_merge_request_path(merge_request.project, merge_request) end diff --git a/app/serializers/note_entity.rb b/app/serializers/note_entity.rb index 7d50e0ff10d..4ccf0bca476 100644 --- a/app/serializers/note_entity.rb +++ b/app/serializers/note_entity.rb @@ -23,6 +23,10 @@ class NoteEntity < API::Entities::Note end end + expose :resolved?, as: :resolved + expose :resolvable?, as: :resolvable + expose :resolved_by, using: NoteUserEntity + expose :system_note_icon_name, if: -> (note, _) { note.system? } do |note| SystemNoteHelper.system_note_icon_name(note) end @@ -53,6 +57,14 @@ class NoteEntity < API::Entities::Note end end + expose :resolve_path, if: -> (note, _) { note.part_of_discussion? && note.resolvable? } do |note| + resolve_project_merge_request_discussion_path(note.project, note.noteable, note.discussion_id) + end + + expose :resolve_with_issue_path, if: -> (note, _) { note.part_of_discussion? && note.resolvable? } do |note| + new_project_issue_path(note.project, merge_request_to_resolve_discussions_of: note.noteable.iid, discussion_to_resolve: note.discussion_id) + 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) diff --git a/app/views/projects/issues/_discussion.html.haml b/app/views/projects/issues/_discussion.html.haml index 11b5e02f1e0..cdfc3e232c5 100644 --- a/app/views/projects/issues/_discussion.html.haml +++ b/app/views/projects/issues/_discussion.html.haml @@ -6,14 +6,6 @@ = 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' %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, - close_issue_path: issue_path(@issue, issue: { state_event: :close }, format: 'json'), - reopen_issue_path: issue_path(@issue, issue: { state_event: :reopen }, format: 'json'), - last_fetched_at: Time.now.to_i, - noteable_data: serialize_issuable(@issue), - current_user_data: UserSerializer.new.represent(current_user, only_path: true).to_json } } + #js-vue-notes{ data: { notes_data: notes_data(@issue), + noteable_data: serialize_issuable(@issue), + current_user_data: UserSerializer.new.represent(current_user, only_path: true).to_json } } diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index d63443c9da5..77e6cb4d2cd 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -73,7 +73,7 @@ .content-block.emoji-block .row - .col-sm-8.js-issue-note-awards + .col-sm-8.js-noteable-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/show.html.haml b/app/views/projects/merge_requests/show.html.haml index 7cbc984ef19..f2e35ef6e0c 100644 --- a/app/views/projects/merge_requests/show.html.haml +++ b/app/views/projects/merge_requests/show.html.haml @@ -1,3 +1,4 @@ +- @gfm_form = true - @content_class = "limit-container-width" unless fluid_layout - add_to_breadcrumbs "Merge Requests", project_merge_requests_path(@project) - breadcrumb_title @merge_request.to_reference @@ -7,6 +8,9 @@ - content_for :page_specific_javascripts do = webpack_bundle_tag('common_vue') + - if has_vue_discussions_cookie? + = webpack_bundle_tag('mr_notes') + .merge-request{ data: { mr_action: j(params[:tab].presence || 'show'), url: merge_request_path(@merge_request, format: :json), project_path: project_path(@merge_request.project) } } = render "projects/merge_requests/mr_title" @@ -23,7 +27,7 @@ #js-vue-mr-widget.mr-widget - .content-block.content-block-small.emoji-list-container + .content-block.content-block-small.emoji-list-container.js-noteable-awards = render 'award_emoji/awards_block', awardable: @merge_request, inline: true .merge-request-tabs-holder{ class: ("js-tabs-affix" unless ENV['RAILS_ENV'] == 'test') } @@ -51,28 +55,37 @@ = tab_link_for @merge_request, :diffs do Changes %span.badge= @merge_request.diff_size - #resolve-count-app.line-resolve-all-container.prepend-top-10{ "v-cloak" => true } - %resolve-count{ "inline-template" => true, ":logged-out" => "#{current_user.nil?}" } - %div - .line-resolve-all{ "v-show" => "discussionCount > 0", - ":class" => "{ 'has-next-btn': !loggedOut && resolvedDiscussionCount !== discussionCount }" } - %span.line-resolve-btn.is-disabled{ type: "button", - ":class" => "{ 'is-active': resolvedDiscussionCount === discussionCount }" } - %template{ 'v-if' => 'resolvedDiscussionCount === discussionCount' } - = render 'shared/icons/icon_status_success_solid.svg' - %template{ 'v-else' => '' } - = render 'shared/icons/icon_resolve_discussion.svg' - %span.line-resolve-text - {{ resolvedDiscussionCount }}/{{ discussionCount }} {{ resolvedCountText }} resolved - = render "discussions/new_issue_for_all_discussions", merge_request: @merge_request - = render "discussions/jump_to_next" + + - if has_vue_discussions_cookie? + #js-vue-discussion-counter + - else + #resolve-count-app.line-resolve-all-container.prepend-top-10{ "v-cloak" => true } + %resolve-count{ "inline-template" => true, ":logged-out" => "#{current_user.nil?}" } + %div + .line-resolve-all{ "v-show" => "discussionCount > 0", + ":class" => "{ 'has-next-btn': !loggedOut && resolvedDiscussionCount !== discussionCount }" } + %span.line-resolve-btn.is-disabled{ type: "button", + ":class" => "{ 'is-active': resolvedDiscussionCount === discussionCount }" } + %template{ 'v-if' => 'resolvedDiscussionCount === discussionCount' } + = render 'shared/icons/icon_status_success_solid.svg' + %template{ 'v-else' => '' } + = render 'shared/icons/icon_resolve_discussion.svg' + %span.line-resolve-text + {{ resolvedDiscussionCount }}/{{ discussionCount }} {{ resolvedCountText }} resolved + = render "discussions/new_issue_for_all_discussions", merge_request: @merge_request + = render "discussions/jump_to_next" .tab-content#diff-notes-app #notes.notes.tab-pane.voting_notes .row %section.col-md-12 - .issuable-discussion + %script.js-notes-data{ type: "application/json" }= initial_notes_data(true).to_json.html_safe + .issuable-discussion.js-vue-notes-event = render "projects/merge_requests/discussion" + - if has_vue_discussions_cookie? + #js-vue-mr-discussions{ data: { notes_data: notes_data(@merge_request), + noteable_data: serialize_issuable(@merge_request), + current_user_data: UserSerializer.new.represent(current_user).to_json} } #commits.commits.tab-pane -# This tab is always loaded via AJAX diff --git a/app/views/shared/notes/_notes_with_form.html.haml b/app/views/shared/notes/_notes_with_form.html.haml index b3f865c5b47..0ceca367883 100644 --- a/app/views/shared/notes/_notes_with_form.html.haml +++ b/app/views/shared/notes/_notes_with_form.html.haml @@ -1,13 +1,14 @@ - issuable = @issue || @merge_request - discussion_locked = issuable&.discussion_locked? -%ul#notes-list.notes.main-notes-list.timeline - = render "shared/notes/notes" +- unless has_vue_discussions_cookie? + %ul#notes-list.notes.main-notes-list.timeline + = render "shared/notes/notes" = render 'shared/notes/edit_form', project: @project - if can_create_note? - %ul.notes.notes-form.timeline + %ul.notes.notes-form.timeline{ :class => ('hidden' if has_vue_discussions_cookie?) } %li.timeline-entry .timeline-entry-inner .flash-container.timeline-content diff --git a/config/routes/project.rb b/config/routes/project.rb index 8fe545b721e..dd1a9c3be3b 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -103,6 +103,7 @@ constraints(ProjectUrlConstrainer.new) do post :toggle_subscription post :remove_wip post :assign_related_issues + get :discussions, format: :json post :rebase scope constraints: { format: nil }, action: :show do diff --git a/config/webpack.config.js b/config/webpack.config.js index 613ed1cad03..f9c10ff5986 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -52,6 +52,7 @@ function generateEntries() { help: './help/help.js', merge_conflicts: './merge_conflicts/merge_conflicts_bundle.js', monitoring: './monitoring/monitoring_bundle.js', + mr_notes: './mr_notes/index.js', notebook_viewer: './blob/notebook_viewer.js', pdf_viewer: './blob/pdf_viewer.js', pipelines_details: './pipelines/pipeline_details_bundle.js', @@ -243,6 +244,7 @@ var config = { 'groups', 'merge_conflicts', 'monitoring', + 'mr_notes', 'notebook_viewer', 'pdf_viewer', 'pipelines', diff --git a/spec/controllers/projects/discussions_controller_spec.rb b/spec/controllers/projects/discussions_controller_spec.rb index 00328d3ea51..fcb0c2f28c8 100644 --- a/spec/controllers/projects/discussions_controller_spec.rb +++ b/spec/controllers/projects/discussions_controller_spec.rb @@ -71,6 +71,19 @@ describe Projects::DiscussionsController do expect(response).to have_gitlab_http_status(200) end + + context "when vue_mr_discussions cookie is present" do + before do + allow(controller).to receive(:cookies).and_return(vue_mr_discussions: 'true') + end + + it "renders discussion with serializer" do + expect_any_instance_of(DiscussionSerializer).to receive(:represent) + .with(instance_of(Discussion), { context: instance_of(described_class) }) + + post :resolve, request_params + end + end end end end @@ -119,6 +132,19 @@ describe Projects::DiscussionsController do expect(response).to have_gitlab_http_status(200) end + + context "when vue_mr_discussions cookie is present" do + before do + allow(controller).to receive(:cookies).and_return({ vue_mr_discussions: 'true' }) + end + + it "renders discussion with serializer" do + expect_any_instance_of(DiscussionSerializer).to receive(:represent) + .with(instance_of(Discussion), { context: instance_of(described_class) }) + + delete :unresolve, request_params + end + end end end end diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb index 9656e7f7e74..9918d52e402 100644 --- a/spec/controllers/projects/issues_controller_spec.rb +++ b/spec/controllers/projects/issues_controller_spec.rb @@ -974,7 +974,7 @@ describe Projects::IssuesController do it 'returns discussion json' do get :discussions, namespace_id: project.namespace, project_id: project, id: issue.iid - expect(json_response.first.keys).to match_array(%w[id reply_id expanded notes individual_note]) + expect(json_response.first.keys).to match_array(%w[id reply_id expanded notes diff_discussion individual_note resolvable resolve_with_issue_path resolved]) end context 'with cross-reference system note', :request_store do diff --git a/spec/features/merge_request/user_posts_notes_spec.rb b/spec/features/merge_request/user_posts_notes_spec.rb index 50d06565fc0..b54addce993 100644 --- a/spec/features/merge_request/user_posts_notes_spec.rb +++ b/spec/features/merge_request/user_posts_notes_spec.rb @@ -144,7 +144,7 @@ describe 'Merge request > User posts notes', :js do end end - describe 'deleting an attachment' do + describe 'deleting attachment on legacy diff note' do before do find('.note').hover diff --git a/spec/fixtures/api/schemas/entities/merge_request_widget.json b/spec/fixtures/api/schemas/entities/merge_request_widget.json index 05461787f06..cfbeec58a45 100644 --- a/spec/fixtures/api/schemas/entities/merge_request_widget.json +++ b/spec/fixtures/api/schemas/entities/merge_request_widget.json @@ -75,7 +75,9 @@ "properties": { "can_remove_source_branch": { "type": "boolean" }, "can_revert_on_current_merge_request": { "type": ["boolean", "null"] }, - "can_cherry_pick_on_current_merge_request": { "type": ["boolean", "null"] } + "can_cherry_pick_on_current_merge_request": { "type": ["boolean", "null"] }, + "can_create_note": { "type": "boolean" }, + "can_update": { "type": "boolean" } }, "additionalProperties": false }, @@ -103,6 +105,7 @@ "merge_ongoing": { "type": "boolean" }, "ff_only_enabled": { "type": ["boolean", false] }, "should_be_rebased": { "type": "boolean" }, + "create_note_path": { "type": ["string", "null"] }, "rebase_commit_sha": { "type": ["string", "null"] }, "rebase_in_progress": { "type": "boolean" }, "can_push_to_source_branch": { "type": "boolean" }, diff --git a/spec/javascripts/autosave_spec.js b/spec/javascripts/autosave_spec.js index 9f9acc392c2..b568d7fa8b0 100644 --- a/spec/javascripts/autosave_spec.js +++ b/spec/javascripts/autosave_spec.js @@ -3,28 +3,24 @@ import AccessorUtilities from '~/lib/utils/accessor'; describe('Autosave', () => { let autosave; + const field = $(''); + const key = 'key'; describe('class constructor', () => { - const key = 'key'; - const field = jasmine.createSpyObj('field', ['data', 'on']); - beforeEach(() => { spyOn(AccessorUtilities, 'isLocalStorageAccessSafe').and.returnValue(true); spyOn(Autosave.prototype, 'restore'); - - autosave = new Autosave(field, key); }); it('should set .isLocalStorageAvailable', () => { + autosave = new Autosave(field, key); + expect(AccessorUtilities.isLocalStorageAccessSafe).toHaveBeenCalled(); expect(autosave.isLocalStorageAvailable).toBe(true); }); }); describe('restore', () => { - const key = 'key'; - const field = jasmine.createSpyObj('field', ['trigger']); - beforeEach(() => { autosave = { field, @@ -49,24 +45,53 @@ describe('Autosave', () => { describe('if .isLocalStorageAvailable is `true`', () => { beforeEach(() => { autosave.isLocalStorageAvailable = true; - - Autosave.prototype.restore.call(autosave); }); it('should call .getItem', () => { + Autosave.prototype.restore.call(autosave); + expect(window.localStorage.getItem).toHaveBeenCalledWith(key); }); + + it('triggers jquery event', () => { + spyOn(autosave.field, 'trigger').and.callThrough(); + + Autosave.prototype.restore.call(autosave); + + expect( + field.trigger, + ).toHaveBeenCalled(); + }); + + it('triggers native event', (done) => { + autosave.field.get(0).addEventListener('change', () => { + done(); + }); + + Autosave.prototype.restore.call(autosave); + }); + }); + + describe('if field gets deleted from DOM', () => { + beforeEach(() => { + autosave.field = $('.not-a-real-element'); + }); + + it('does not trigger event', () => { + spyOn(field, 'trigger').and.callThrough(); + + expect( + field.trigger, + ).not.toHaveBeenCalled(); + }); }); }); describe('save', () => { - const field = jasmine.createSpyObj('field', ['val']); - beforeEach(() => { autosave = jasmine.createSpyObj('autosave', ['reset']); autosave.field = field; - - field.val.and.returnValue('value'); + field.val('value'); spyOn(window.localStorage, 'setItem'); }); @@ -97,8 +122,6 @@ describe('Autosave', () => { }); describe('reset', () => { - const key = 'key'; - beforeEach(() => { autosave = { key, diff --git a/spec/javascripts/fixtures/merge_requests.rb b/spec/javascripts/fixtures/merge_requests.rb index 3fd16d76f51..ee60489eb7c 100644 --- a/spec/javascripts/fixtures/merge_requests.rb +++ b/spec/javascripts/fixtures/merge_requests.rb @@ -70,8 +70,50 @@ describe Projects::MergeRequestsController, '(JavaScript fixtures)', type: :cont render_merge_request(example.description, merge_request) end + it 'merge_requests/discussions.json' do |example| + create(:diff_note_on_merge_request, project: project, author: admin, position: position, noteable: merge_request) + render_discussions_json(merge_request, example.description) + end + + it 'merge_requests/diff_discussion.json' do |example| + create(:diff_note_on_merge_request, project: project, author: admin, position: position, noteable: merge_request) + render_discussions_json(merge_request, example.description) + end + + context 'with image diff' do + let(:merge_request2) { create(:merge_request_with_diffs, :with_image_diffs, source_project: project, title: "Added images") } + let(:image_path) { "files/images/ee_repo_logo.png" } + let(:image_position) do + Gitlab::Diff::Position.new( + old_path: image_path, + new_path: image_path, + width: 100, + height: 100, + x: 1, + y: 1, + position_type: "image", + diff_refs: merge_request2.diff_refs + ) + end + + it 'merge_requests/image_diff_discussion.json' do |example| + create(:diff_note_on_merge_request, project: project, noteable: merge_request2, position: image_position) + render_discussions_json(merge_request2, example.description) + end + end + private + def render_discussions_json(merge_request, fixture_file_name) + get :discussions, + namespace_id: project.namespace.to_param, + project_id: project, + id: merge_request.to_param, + format: :json + + store_frontend_fixture(response, fixture_file_name) + end + def render_merge_request(fixture_file_name, merge_request) get :show, namespace_id: project.namespace.to_param, diff --git a/spec/javascripts/notes/components/comment_form_spec.js b/spec/javascripts/notes/components/comment_form_spec.js index 104d03377b6..6a7131528a3 100644 --- a/spec/javascripts/notes/components/comment_form_spec.js +++ b/spec/javascripts/notes/components/comment_form_spec.js @@ -1,17 +1,20 @@ import Vue from 'vue'; import Autosize from 'autosize'; import store from '~/notes/stores'; -import issueCommentForm from '~/notes/components/comment_form.vue'; +import CommentForm from '~/notes/components/comment_form.vue'; import { loggedOutnoteableData, notesDataMock, userDataMock, noteableDataMock } from '../mock_data'; import { keyboardDownEvent } from '../../issue_show/helpers'; describe('issue_comment_form component', () => { let vm; - const Component = Vue.extend(issueCommentForm); + const Component = Vue.extend(CommentForm); let mountComponent; beforeEach(() => { - mountComponent = () => new Component({ + mountComponent = (noteableType = 'issue') => new Component({ + propsData: { + noteableType, + }, store, }).$mount(); }); @@ -136,6 +139,11 @@ describe('issue_comment_form component', () => { expect(vm.editCurrentUserLastNote).toHaveBeenCalled(); }); + + it('inits autosave', () => { + expect(vm.autosave).toBeDefined(); + expect(vm.autosave.key).toEqual(`autosave/Note/Issue/${noteableDataMock.id}`); + }); }); describe('event enter', () => { @@ -182,6 +190,15 @@ describe('issue_comment_form component', () => { done(); }); }); + + it('updates button text with noteable type', (done) => { + vm.noteableType = 'merge_request'; + + Vue.nextTick(() => { + expect(vm.$el.querySelector('.btn-comment-and-close').textContent.trim()).toEqual('Close merge request'); + done(); + }); + }); }); describe('issue is confidential', () => { diff --git a/spec/javascripts/notes/components/diff_file_header_spec.js b/spec/javascripts/notes/components/diff_file_header_spec.js new file mode 100644 index 00000000000..aed30a087a6 --- /dev/null +++ b/spec/javascripts/notes/components/diff_file_header_spec.js @@ -0,0 +1,93 @@ +import Vue from 'vue'; +import DiffFileHeader from '~/notes/components/diff_file_header.vue'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import mountComponent from '../../helpers/vue_mount_component_helper'; + +const discussionFixture = 'merge_requests/diff_discussion.json'; + +describe('diff_file_header', () => { + let vm; + const diffDiscussionMock = getJSONFixture(discussionFixture)[0]; + const diffFile = convertObjectPropsToCamelCase(diffDiscussionMock.diff_file); + const props = { + diffFile, + }; + const Component = Vue.extend(DiffFileHeader); + const selectors = { + get copyButton() { + return vm.$el.querySelector('button[data-original-title="Copy file path to clipboard"]'); + }, + get fileName() { + return vm.$el.querySelector('.file-title-name'); + }, + get titleWrapper() { + return vm.$refs.titleWrapper; + }, + }; + + describe('submodule', () => { + beforeEach(() => { + props.diffFile.submodule = true; + props.diffFile.submoduleLink = 'Submodule'; + + vm = mountComponent(Component, props); + }); + + it('shows submoduleLink', () => { + expect(selectors.fileName.innerHTML).toBe(props.diffFile.submoduleLink); + }); + + it('has button to copy blob path', () => { + expect(selectors.copyButton).toExist(); + expect(selectors.copyButton.getAttribute('data-clipboard-text')).toBe(props.diffFile.submoduleLink); + }); + }); + + describe('changed file', () => { + beforeEach(() => { + props.diffFile.submodule = false; + props.diffFile.discussionPath = 'some/discussion/id'; + + vm = mountComponent(Component, props); + }); + + it('shows file type icon', () => { + expect(vm.$el.innerHTML).toContain('fa-file-text-o'); + }); + + it('links to discussion path', () => { + expect(selectors.titleWrapper).toExist(); + expect(selectors.titleWrapper.tagName).toBe('A'); + expect(selectors.titleWrapper.getAttribute('href')).toBe(props.diffFile.discussionPath); + }); + + it('shows plain title if no link given', () => { + props.diffFile.discussionPath = undefined; + vm = mountComponent(Component, props); + + expect(selectors.titleWrapper.tagName).not.toBe('A'); + expect(selectors.titleWrapper.href).toBeFalsy(); + }); + + it('has button to copy file path', () => { + expect(selectors.copyButton).toExist(); + expect(selectors.copyButton.getAttribute('data-clipboard-text')).toBe(props.diffFile.filePath); + }); + + it('shows file mode change', (done) => { + vm.diffFile = { + ...props.diffFile, + modeChanged: true, + aMode: '100755', + bMode: '100644', + }; + + Vue.nextTick(() => { + expect( + vm.$refs.fileMode.textContent.trim(), + ).toBe('100755 → 100644'); + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/notes/components/diff_with_note_spec.js b/spec/javascripts/notes/components/diff_with_note_spec.js new file mode 100644 index 00000000000..7f1f4bf0bcd --- /dev/null +++ b/spec/javascripts/notes/components/diff_with_note_spec.js @@ -0,0 +1,64 @@ +import Vue from 'vue'; +import DiffWithNote from '~/notes/components/diff_with_note.vue'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import mountComponent from '../../helpers/vue_mount_component_helper'; + +const discussionFixture = 'merge_requests/diff_discussion.json'; +const imageDiscussionFixture = 'merge_requests/image_diff_discussion.json'; + +describe('diff_with_note', () => { + let vm; + const diffDiscussionMock = getJSONFixture(discussionFixture)[0]; + const diffDiscussion = convertObjectPropsToCamelCase(diffDiscussionMock); + const Component = Vue.extend(DiffWithNote); + const props = { + discussion: diffDiscussion, + }; + const selectors = { + get container() { + return vm.$refs.fileHolder; + }, + get diffTable() { + return this.container.querySelector('.diff-content table'); + }, + get diffRows() { + return this.container.querySelectorAll('.diff-content .line_holder'); + }, + get noteRow() { + return this.container.querySelector('.diff-content .notes_holder'); + }, + }; + + describe('text diff', () => { + beforeEach(() => { + vm = mountComponent(Component, props); + }); + + it('shows text diff', () => { + expect(selectors.container).toHaveClass('text-file'); + expect(selectors.diffTable).toExist(); + }); + + it('shows diff lines', () => { + expect(selectors.diffRows.length).toBe(12); + }); + + it('shows notes row', () => { + expect(selectors.noteRow).toExist(); + }); + }); + + describe('image diff', () => { + beforeEach(() => { + const imageDiffDiscussionMock = getJSONFixture(imageDiscussionFixture)[0]; + props.discussion = convertObjectPropsToCamelCase(imageDiffDiscussionMock); + }); + + it('shows image diff', () => { + vm = mountComponent(Component, props); + + expect(selectors.container).toHaveClass('js-image-file'); + expect(selectors.diffTable).not.toExist(); + }); + }); +}); diff --git a/spec/javascripts/notes/components/note_app_spec.js b/spec/javascripts/notes/components/note_app_spec.js index 12d180137a0..e1c612f5100 100644 --- a/spec/javascripts/notes/components/note_app_spec.js +++ b/spec/javascripts/notes/components/note_app_spec.js @@ -24,6 +24,7 @@ describe('note_app', () => { beforeEach(() => { jasmine.addMatchers(vueMatchers); + $('body').attr('data-page', 'projects:merge_requests:show'); const IssueNotesApp = Vue.extend(notesApp); @@ -119,8 +120,8 @@ describe('note_app', () => { vm = mountComponent(); }); - it('should render loading icon', () => { - expect(vm).toIncludeElement('.js-loading'); + it('renders skeleton notes', () => { + expect(vm).toIncludeElement('.animation-container'); }); it('should render form', () => { diff --git a/spec/javascripts/notes/components/note_body_spec.js b/spec/javascripts/notes/components/note_body_spec.js index b42e7943b98..0ff804f0e55 100644 --- a/spec/javascripts/notes/components/note_body_spec.js +++ b/spec/javascripts/notes/components/note_body_spec.js @@ -30,17 +30,26 @@ describe('issue_note_body component', () => { expect(vm.$el.querySelector('.note-text').innerHTML).toEqual(note.note_html); }); - it('should be render form if user is editing', (done) => { - vm.isEditing = true; + it('should render awards list', () => { + expect(vm.$el.querySelector('.js-awards-block button [data-name="baseball"]')).not.toBeNull(); + expect(vm.$el.querySelector('.js-awards-block button [data-name="bath_tone3"]')).not.toBeNull(); + }); - Vue.nextTick(() => { - expect(vm.$el.querySelector('textarea.js-task-list-field')).toBeDefined(); - done(); + describe('isEditing', () => { + beforeEach((done) => { + vm.isEditing = true; + Vue.nextTick(done); }); - }); - it('should render awards list', () => { - expect(vm.$el.querySelector('.js-awards-block button [data-name="baseball"]')).toBeDefined(); - expect(vm.$el.querySelector('.js-awards-block button [data-name="bath_tone3"]')).toBeDefined(); + it('renders edit form', () => { + expect(vm.$el.querySelector('textarea.js-task-list-field')).not.toBeNull(); + }); + + it('adds autosave', () => { + const autosaveKey = `autosave/Note/${note.noteable_type}/${note.id}`; + + expect(vm.autosave).toExist(); + expect(vm.autosave.key).toEqual(autosaveKey); + }); }); }); diff --git a/spec/javascripts/notes/components/note_header_spec.js b/spec/javascripts/notes/components/note_header_spec.js index 16a76b11321..5636f8d1a9f 100644 --- a/spec/javascripts/notes/components/note_header_spec.js +++ b/spec/javascripts/notes/components/note_header_spec.js @@ -32,6 +32,7 @@ describe('note_header component', () => { createdAt: '2017-08-02T10:51:58.559Z', includeToggle: false, noteId: 1394, + expanded: true, }, }).$mount(); }); @@ -68,6 +69,7 @@ describe('note_header component', () => { createdAt: '2017-08-02T10:51:58.559Z', includeToggle: true, noteId: 1395, + expanded: true, }, }).$mount(); }); @@ -76,17 +78,35 @@ describe('note_header component', () => { expect(vm.$el.querySelector('.js-vue-toggle-button')).toBeDefined(); }); - it('should toggle the disucssion icon', (done) => { - expect( - vm.$el.querySelector('.js-vue-toggle-button i').classList.contains('fa-chevron-up'), - ).toEqual(true); + it('emits toggle event on click', (done) => { + spyOn(vm, '$emit'); vm.$el.querySelector('.js-vue-toggle-button').click(); + Vue.nextTick(() => { + expect(vm.$emit).toHaveBeenCalledWith('toggleHandler'); + done(); + }); + }); + + it('renders up arrow when open', (done) => { + vm.expanded = true; + + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.js-vue-toggle-button i').classList, + ).toContain('fa-chevron-up'); + done(); + }); + }); + + it('renders down arrow when closed', (done) => { + vm.expanded = false; + Vue.nextTick(() => { expect( - vm.$el.querySelector('.js-vue-toggle-button i').classList.contains('fa-chevron-down'), - ).toEqual(true); + vm.$el.querySelector('.js-vue-toggle-button i').classList, + ).toContain('fa-chevron-down'); done(); }); }); diff --git a/spec/javascripts/notes/mock_data.js b/spec/javascripts/notes/mock_data.js index ccf4bd070c2..bf60cb12f52 100644 --- a/spec/javascripts/notes/mock_data.js +++ b/spec/javascripts/notes/mock_data.js @@ -7,8 +7,9 @@ export const notesDataMock = { notesPath: '/gitlab-org/gitlab-ce/noteable/issue/98/notes', quickActionsDocsPath: '/help/user/project/quick_actions', registerPath: '/users/sign_in?redirect_to_referer=yes#register-pane', - closeIssuePath: '/twitter/flight/issues/9.json?issue%5Bstate_event%5D=close', - reopenIssuePath: '/twitter/flight/issues/9.json?issue%5Bstate_event%5D=reopen', + totalNotes: 1, + closePath: '/twitter/flight/issues/9.json?issue%5Bstate_event%5D=close', + reopenPath: '/twitter/flight/issues/9.json?issue%5Bstate_event%5D=reopen', }; export const userDataMock = { diff --git a/spec/javascripts/notes/stores/getters_spec.js b/spec/javascripts/notes/stores/getters_spec.js index 919ffbfdef0..8b2a8d2cd7a 100644 --- a/spec/javascripts/notes/stores/getters_spec.js +++ b/spec/javascripts/notes/stores/getters_spec.js @@ -56,9 +56,9 @@ describe('Getters Notes Store', () => { }); }); - describe('issueState', () => { + describe('openState', () => { it('should return the issue state', () => { - expect(getters.issueState(state)).toEqual(noteableDataMock.state); + expect(getters.openState(state)).toEqual(noteableDataMock.state); }); }); }); diff --git a/spec/javascripts/notes/stores/mutation_spec.js b/spec/javascripts/notes/stores/mutation_spec.js index 22d99998a7d..e4baefc5bfc 100644 --- a/spec/javascripts/notes/stores/mutation_spec.js +++ b/spec/javascripts/notes/stores/mutation_spec.js @@ -1,7 +1,7 @@ import mutations from '~/notes/stores/mutations'; import { note, discussionMock, notesDataMock, userDataMock, noteableDataMock, individualNote } from '../mock_data'; -describe('Mutation Notes Store', () => { +describe('Notes Store mutations', () => { describe('ADD_NEW_NOTE', () => { let state; let noteData; @@ -103,7 +103,8 @@ describe('Mutation Notes Store', () => { }; mutations.SET_INITIAL_NOTES(state, [note]); - expect(state.notes).toEqual([note]); + expect(state.notes[0].id).toEqual(note.id); + expect(state.notes.length).toEqual(1); }); }); diff --git a/spec/javascripts/notes_spec.js b/spec/javascripts/notes_spec.js index 274d7591c71..d4a148e6ab1 100644 --- a/spec/javascripts/notes_spec.js +++ b/spec/javascripts/notes_spec.js @@ -34,6 +34,7 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; describe('Notes', function() { const FLASH_TYPE_ALERT = 'alert'; + const NOTES_POST_PATH = /(.*)\/notes\?html=true$/; var commentsTemplate = 'merge_requests/merge_request_with_comment.html.raw'; preloadFixtures(commentsTemplate); @@ -154,7 +155,7 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; $form.find('textarea.js-note-text').val(sampleComment); mock = new MockAdapter(axios); - mock.onPost(/(.*)\/notes$/).reply(200, noteEntity); + mock.onPost(NOTES_POST_PATH).reply(200, noteEntity); }); afterEach(() => { @@ -506,11 +507,11 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; let mock; function mockNotesPost() { - mock.onPost(/(.*)\/notes$/).reply(200, note); + mock.onPost(NOTES_POST_PATH).reply(200, note); } function mockNotesPostError() { - mock.onPost(/(.*)\/notes$/).networkError(); + mock.onPost(NOTES_POST_PATH).networkError(); } beforeEach(() => { @@ -631,7 +632,7 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; beforeEach(() => { mock = new MockAdapter(axios); - mock.onPost(/(.*)\/notes$/).reply(200, note); + mock.onPost(NOTES_POST_PATH).reply(200, note); this.notes = new Notes('', []); window.gon.current_username = 'root'; @@ -684,7 +685,7 @@ import timeoutPromise from './helpers/set_timeout_promise_helper'; beforeEach(() => { mock = new MockAdapter(axios); - mock.onPost(/(.*)\/notes$/).reply(200, note); + mock.onPost(NOTES_POST_PATH).reply(200, note); this.notes = new Notes('', []); window.gon.current_username = 'root'; diff --git a/spec/serializers/diff_file_entity_spec.rb b/spec/serializers/diff_file_entity_spec.rb new file mode 100644 index 00000000000..45d7c703df3 --- /dev/null +++ b/spec/serializers/diff_file_entity_spec.rb @@ -0,0 +1,24 @@ +require 'spec_helper' + +describe DiffFileEntity do + include RepoHelpers + + let(:project) { create(:project, :repository) } + let(:repository) { project.repository } + let(:commit) { project.commit(sample_commit.id) } + let(:diff_refs) { commit.diff_refs } + let(:diff) { commit.raw_diffs.first } + let(:diff_file) { Gitlab::Diff::File.new(diff, diff_refs: diff_refs, repository: repository) } + let(:entity) { described_class.new(diff_file) } + + subject { entity.as_json } + + it 'exposes correct attributes' do + expect(subject).to include( + :submodule, :submodule_link, :file_path, + :deleted_file, :old_path, :new_path, :mode_changed, + :a_mode, :b_mode, :text, :old_path_html, + :new_path_html + ) + end +end diff --git a/spec/serializers/discussion_entity_spec.rb b/spec/serializers/discussion_entity_spec.rb new file mode 100644 index 00000000000..7ee8e38af1c --- /dev/null +++ b/spec/serializers/discussion_entity_spec.rb @@ -0,0 +1,36 @@ +require 'spec_helper' + +describe DiscussionEntity do + include RepoHelpers + + let(:user) { create(:user) } + let(:note) { create(:discussion_note_on_merge_request) } + let(:discussion) { note.discussion } + let(:request) { double('request') } + let(:controller) { double('controller') } + let(:entity) { described_class.new(discussion, request: request, context: controller) } + + subject { entity.as_json } + + before do + allow(controller).to receive(:render_to_string) + allow(request).to receive(:current_user).and_return(user) + allow(request).to receive(:noteable).and_return(note.noteable) + end + + it 'exposes correct attributes' do + expect(subject).to include( + :id, :expanded, :notes, :individual_note, + :resolvable, :resolved, :resolve_path, + :resolve_with_issue_path, :diff_discussion + ) + end + + context 'when diff file is present' do + let(:note) { create(:diff_note_on_merge_request) } + + it 'exposes diff file attributes' do + expect(subject).to include(:diff_file, :truncated_diff_lines, :image_diff_html) + end + end +end diff --git a/spec/serializers/note_entity_spec.rb b/spec/serializers/note_entity_spec.rb index 3459cc72063..51a8587ace9 100644 --- a/spec/serializers/note_entity_spec.rb +++ b/spec/serializers/note_entity_spec.rb @@ -48,4 +48,15 @@ describe NoteEntity do expect(subject).to include(:system_note_icon_name) end end + + context 'when note is part of resolvable discussion' do + before do + allow(note).to receive(:part_of_discussion?).and_return(true) + allow(note).to receive(:resolvable?).and_return(true) + end + + it 'exposes paths to resolve note' do + expect(subject).to include(:resolve_path, :resolve_with_issue_path) + end + end end -- cgit v1.2.1 From b5a5fdf0e3b97622789db444bf0bf697e78dbb47 Mon Sep 17 00:00:00 2001 From: Dylan Griffith Date: Thu, 22 Feb 2018 17:03:00 +1100 Subject: Persist runner IP address on contact (#43489) --- app/models/ci/runner.rb | 4 ++-- db/migrate/20180222043024_add_ip_address_to_runner.rb | 9 +++++++++ db/schema.rb | 3 ++- lib/api/helpers/runner.rb | 18 ++++++++++++------ lib/api/runner.rb | 4 ++-- spec/requests/api/runner_spec.rb | 18 ++++++++++++++++++ 6 files changed, 45 insertions(+), 11 deletions(-) create mode 100644 db/migrate/20180222043024_add_ip_address_to_runner.rb diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index 13c784bea0d..609620a62bb 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -49,7 +49,7 @@ module Ci ref_protected: 1 } - cached_attr_reader :version, :revision, :platform, :architecture, :contacted_at + cached_attr_reader :version, :revision, :platform, :architecture, :contacted_at, :ip_address # Searches for runners matching the given query. # @@ -157,7 +157,7 @@ module Ci end def update_cached_info(values) - values = values&.slice(:version, :revision, :platform, :architecture) || {} + values = values&.slice(:version, :revision, :platform, :architecture, :ip_address) || {} values[:contacted_at] = Time.now cache_attributes(values) diff --git a/db/migrate/20180222043024_add_ip_address_to_runner.rb b/db/migrate/20180222043024_add_ip_address_to_runner.rb new file mode 100644 index 00000000000..bf00560b5a8 --- /dev/null +++ b/db/migrate/20180222043024_add_ip_address_to_runner.rb @@ -0,0 +1,9 @@ +class AddIpAddressToRunner < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + add_column :ci_runners, :ip_address, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index 5bb461169f1..3fb80065ff5 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20180216121030) do +ActiveRecord::Schema.define(version: 20180222043024) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -437,6 +437,7 @@ ActiveRecord::Schema.define(version: 20180216121030) do t.boolean "run_untagged", default: true, null: false t.boolean "locked", default: false, null: false t.integer "access_level", default: 0, null: false + t.string "ip_address" end add_index "ci_runners", ["contacted_at"], name: "index_ci_runners_on_contacted_at", using: :btree diff --git a/lib/api/helpers/runner.rb b/lib/api/helpers/runner.rb index fbe30192a16..35ac0b4cbca 100644 --- a/lib/api/helpers/runner.rb +++ b/lib/api/helpers/runner.rb @@ -9,16 +9,22 @@ module API Gitlab::CurrentSettings.runners_registration_token) end - def get_runner_version_from_params - return unless params['info'].present? + def authenticate_runner! + forbidden! unless current_runner - attributes_for_keys(%w(name version revision platform architecture), params['info']) + current_runner + .update_cached_info(get_runner_details_from_request) end - def authenticate_runner! - forbidden! unless current_runner + def get_runner_details_from_request + return get_runner_ip unless params['info'].present? + + attributes_for_keys(%w(name version revision platform architecture), params['info']) + .merge(get_runner_ip) + end - current_runner.update_cached_info(get_runner_version_from_params) + def get_runner_ip + { ip_address: request.ip } end def current_runner diff --git a/lib/api/runner.rb b/lib/api/runner.rb index 5469cba69a6..91cdc564002 100644 --- a/lib/api/runner.rb +++ b/lib/api/runner.rb @@ -16,7 +16,8 @@ module API optional :tag_list, type: Array[String], desc: %q(List of Runner's tags) end post '/' do - attributes = attributes_for_keys [:description, :locked, :run_untagged, :tag_list] + attributes = attributes_for_keys([:description, :locked, :run_untagged, :tag_list]) + .merge(get_runner_details_from_request) runner = if runner_registration_token_valid? @@ -30,7 +31,6 @@ module API return forbidden! unless runner if runner.id - runner.update(get_runner_version_from_params) present runner, with: Entities::RunnerRegistrationDetails else not_found! diff --git a/spec/requests/api/runner_spec.rb b/spec/requests/api/runner_spec.rb index f10b6e43d09..4021e537efc 100644 --- a/spec/requests/api/runner_spec.rb +++ b/spec/requests/api/runner_spec.rb @@ -122,6 +122,15 @@ describe API::Runner do end end end + + it "sets the runner's ip_address" do + post api('/runners'), + { token: registration_token }, + { 'REMOTE_ADDR' => '123.111.123.111' } + + expect(response).to have_gitlab_http_status 201 + expect(Ci::Runner.first.ip_address).to eq('123.111.123.111') + end end describe 'DELETE /api/v4/runners' do @@ -422,6 +431,15 @@ describe API::Runner do end end + it "sets the runner's ip_address" do + post api('/jobs/request'), + { token: runner.token }, + { 'User-Agent' => user_agent, 'REMOTE_ADDR' => '123.222.123.222' } + + expect(response).to have_gitlab_http_status 201 + expect(runner.reload.ip_address).to eq('123.222.123.222') + end + context 'when concurrently updating a job' do before do expect_any_instance_of(Ci::Build).to receive(:run!) -- cgit v1.2.1 From d52c3be108ef59af47317b97c50026ae81252590 Mon Sep 17 00:00:00 2001 From: Dylan Griffith Date: Mon, 26 Feb 2018 14:31:36 +1100 Subject: Add ip_address to admin runner form (#43489) --- app/views/projects/runners/_form.html.haml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/views/projects/runners/_form.html.haml b/app/views/projects/runners/_form.html.haml index e660fce652f..49c90869146 100644 --- a/app/views/projects/runners/_form.html.haml +++ b/app/views/projects/runners/_form.html.haml @@ -29,6 +29,11 @@ Token .col-sm-10 = f.text_field :token, class: 'form-control', readonly: true + .form-group + = label_tag :ip_address, class: 'control-label' do + IP Address + .col-sm-10 + = f.text_field :ip_address, class: 'form-control', readonly: true .form-group = label_tag :description, class: 'control-label' do Description -- cgit v1.2.1 From e89ae3b914a5b3ae5c7bd0ab98858e20ed974902 Mon Sep 17 00:00:00 2001 From: Dylan Griffith Date: Mon, 26 Feb 2018 14:34:50 +1100 Subject: Add IP Address to admin runners table (#43489) --- app/views/admin/runners/_runner.html.haml | 2 ++ app/views/admin/runners/index.html.haml | 1 + 2 files changed, 3 insertions(+) diff --git a/app/views/admin/runners/_runner.html.haml b/app/views/admin/runners/_runner.html.haml index 140688b52d3..e1cee584929 100644 --- a/app/views/admin/runners/_runner.html.haml +++ b/app/views/admin/runners/_runner.html.haml @@ -16,6 +16,8 @@ = runner.description %td = runner.version + %td + = runner.ip_address %td - if runner.shared? n/a diff --git a/app/views/admin/runners/index.html.haml b/app/views/admin/runners/index.html.haml index abec3607cab..9f13dbbbd82 100644 --- a/app/views/admin/runners/index.html.haml +++ b/app/views/admin/runners/index.html.haml @@ -60,6 +60,7 @@ %th Runner token %th Description %th Version + %th IP Address %th Projects %th Jobs %th Tags -- cgit v1.2.1 From de879edb02de1adb9c178fcc87f979c52ff660b1 Mon Sep 17 00:00:00 2001 From: Dylan Griffith Date: Mon, 26 Feb 2018 14:40:43 +1100 Subject: Add IP Address to project runner show page (#43489) --- app/views/projects/runners/show.html.haml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/views/projects/runners/show.html.haml b/app/views/projects/runners/show.html.haml index dfab04aa1fb..4e57f5f844d 100644 --- a/app/views/projects/runners/show.html.haml +++ b/app/views/projects/runners/show.html.haml @@ -40,6 +40,9 @@ %tr %td Version %td= @runner.version + %tr + %td IP Address + %td= @runner.ip_address %tr %td Revision %td= @runner.revision -- cgit v1.2.1 From 8a49b1dfed8a7f74eb236465f4ae90c28aab406f Mon Sep 17 00:00:00 2001 From: Dylan Griffith Date: Wed, 28 Feb 2018 10:10:05 +1100 Subject: Add changelog for Display Runner IP Address (#43489) --- changelogs/unreleased/43489-display-runner-ip.yml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changelogs/unreleased/43489-display-runner-ip.yml diff --git a/changelogs/unreleased/43489-display-runner-ip.yml b/changelogs/unreleased/43489-display-runner-ip.yml new file mode 100644 index 00000000000..621c2ec709a --- /dev/null +++ b/changelogs/unreleased/43489-display-runner-ip.yml @@ -0,0 +1,5 @@ +--- +title: Display Runner IP Address +merge_request: 17286 +author: +type: added -- cgit v1.2.1 From b3c096cddc395080f563a35f933620294f7cbcb0 Mon Sep 17 00:00:00 2001 From: Sean McGivern Date: Wed, 28 Feb 2018 10:22:04 +0000 Subject: Fix code search results with non-ASCII data Gitlab::Git::Popen#popen now returns ASCII-8BIT (binary) data always, so we need to handle that explicitly. --- ...le-character-encodings-utf-8-and-ascii-8bit.yml | 5 ++++ lib/gitlab/search_results.rb | 4 +++- spec/lib/gitlab/project_search_results_spec.rb | 27 ++++++++++++++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 changelogs/unreleased/43531-500-error-searching-wiki-incompatible-character-encodings-utf-8-and-ascii-8bit.yml diff --git a/changelogs/unreleased/43531-500-error-searching-wiki-incompatible-character-encodings-utf-8-and-ascii-8bit.yml b/changelogs/unreleased/43531-500-error-searching-wiki-incompatible-character-encodings-utf-8-and-ascii-8bit.yml new file mode 100644 index 00000000000..173710412a5 --- /dev/null +++ b/changelogs/unreleased/43531-500-error-searching-wiki-incompatible-character-encodings-utf-8-and-ascii-8bit.yml @@ -0,0 +1,5 @@ +--- +title: Fix code and wiki search results pages when non-ASCII text is displayed +merge_request: 17413 +author: +type: fixed diff --git a/lib/gitlab/search_results.rb b/lib/gitlab/search_results.rb index 5a5ae7f19d4..781783f4d97 100644 --- a/lib/gitlab/search_results.rb +++ b/lib/gitlab/search_results.rb @@ -1,6 +1,8 @@ module Gitlab class SearchResults class FoundBlob + include EncodingHelper + attr_reader :id, :filename, :basename, :ref, :startline, :data, :project_id def initialize(opts = {}) @@ -9,7 +11,7 @@ module Gitlab @basename = opts.fetch(:basename, nil) @ref = opts.fetch(:ref, nil) @startline = opts.fetch(:startline, nil) - @data = opts.fetch(:data, nil) + @data = encode_utf8(opts.fetch(:data, nil)) @per_page = opts.fetch(:per_page, 20) @project_id = opts.fetch(:project_id, nil) end diff --git a/spec/lib/gitlab/project_search_results_spec.rb b/spec/lib/gitlab/project_search_results_spec.rb index 1ebb0105cf5..d8250e4b4c6 100644 --- a/spec/lib/gitlab/project_search_results_spec.rb +++ b/spec/lib/gitlab/project_search_results_spec.rb @@ -1,3 +1,4 @@ +# coding: utf-8 require 'spec_helper' describe Gitlab::ProjectSearchResults do @@ -105,6 +106,32 @@ describe Gitlab::ProjectSearchResults do end end + context 'when the search returns non-ASCII data' do + context 'with UTF-8' do + let(:results) { project.repository.search_files_by_content("файл", 'master') } + + it 'returns results as UTF-8' do + expect(subject.filename).to eq('encoding/russian.rb') + expect(subject.basename).to eq('encoding/russian') + expect(subject.ref).to eq('master') + expect(subject.startline).to eq(1) + expect(subject.data).to eq("Хороший файл") + end + end + + context 'with ISO-8859-1' do + let(:search_result) { "master:encoding/iso8859.txt\x001\x00\xC4\xFC\nmaster:encoding/iso8859.txt\x002\x00\nmaster:encoding/iso8859.txt\x003\x00foo\n".force_encoding(Encoding::ASCII_8BIT) } + + it 'returns results as UTF-8' do + expect(subject.filename).to eq('encoding/iso8859.txt') + expect(subject.basename).to eq('encoding/iso8859') + expect(subject.ref).to eq('master') + expect(subject.startline).to eq(1) + expect(subject.data).to eq("Äü\n\nfoo") + end + end + end + context "when filename has extension" do let(:search_result) { "master:CONTRIBUTE.md\x005\x00- [Contribute to GitLab](#contribute-to-gitlab)\n" } -- cgit v1.2.1 From 089236976be55a02ed3371521a94289a83f479b0 Mon Sep 17 00:00:00 2001 From: Filipa Lacerda Date: Wed, 28 Feb 2018 10:59:48 +0000 Subject: Fix example in Vuex docs --- doc/development/fe_guide/vue.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/doc/development/fe_guide/vue.md b/doc/development/fe_guide/vue.md index 6c93c29124d..09957feee17 100644 --- a/doc/development/fe_guide/vue.md +++ b/doc/development/fe_guide/vue.md @@ -507,6 +507,7 @@ This is the entry point for our store. You can use the following as a guide: import Vue from 'vue'; import Vuex from 'vuex'; import * as actions from './actions'; +import * as getters from './getters'; import * as mutations from './mutations'; Vue.use(Vuex); @@ -514,6 +515,7 @@ Vue.use(Vuex); export default new Vuex.Store({ actions, getters, + mutations, state: { users: [], }, -- cgit v1.2.1 From 417e3ba8e3f02361a8fecd116f269fe69732a3b5 Mon Sep 17 00:00:00 2001 From: Bob Van Landuyt Date: Tue, 27 Feb 2018 12:03:22 +0100 Subject: Specify base controller for Doorkeeper This will make sure the `ApplicantionController#can?` method is available for views rendering menus based on the current user's abilities. --- config/initializers/doorkeeper.rb | 2 ++ spec/controllers/oauth/authorizations_controller_spec.rb | 2 ++ 2 files changed, 4 insertions(+) diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb index b89f0419b91..2079d3acb72 100644 --- a/config/initializers/doorkeeper.rb +++ b/config/initializers/doorkeeper.rb @@ -103,4 +103,6 @@ Doorkeeper.configure do # Some applications require dynamic query parameters on their request_uri # set to true if you want this to be allowed # wildcard_redirect_uri false + + base_controller 'ApplicationController' end diff --git a/spec/controllers/oauth/authorizations_controller_spec.rb b/spec/controllers/oauth/authorizations_controller_spec.rb index 004b463e745..149b690ff70 100644 --- a/spec/controllers/oauth/authorizations_controller_spec.rb +++ b/spec/controllers/oauth/authorizations_controller_spec.rb @@ -34,6 +34,8 @@ describe Oauth::AuthorizationsController do end context 'with valid params' do + render_views + it 'returns 200 code and renders view' do get :new, params -- cgit v1.2.1 From 611ac8e3423d70e85f02a54f5077438595623137 Mon Sep 17 00:00:00 2001 From: Kushal Pandya Date: Tue, 27 Feb 2018 15:04:51 +0530 Subject: Make label filter URL dynamic --- app/assets/javascripts/labels_select.js | 40 +++++++++++++++++++++++---------- 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js index 89a246f56cf..9b46bbf83da 100644 --- a/app/assets/javascripts/labels_select.js +++ b/app/assets/javascripts/labels_select.js @@ -21,7 +21,7 @@ export default class LabelsSelect { } $els.each(function(i, dropdown) { - var $block, $colorPreview, $dropdown, $form, $loading, $selectbox, $sidebarCollapsedValue, $value, abilityName, defaultLabel, enableLabelCreateButton, issueURLSplit, issueUpdateURL, labelHTMLTemplate, labelNoneHTMLTemplate, labelUrl, namespacePath, projectPath, saveLabelData, selectedLabel, showAny, showNo, $sidebarLabelTooltip, initialSelected, $toggleText, fieldName, useId, propertyName, showMenuAbove, $container, $dropdownContainer; + var $block, $colorPreview, $dropdown, $form, $loading, $selectbox, $sidebarCollapsedValue, $value, abilityName, defaultLabel, enableLabelCreateButton, issueURLSplit, issueUpdateURL, labelUrl, namespacePath, projectPath, saveLabelData, selectedLabel, showAny, showNo, $sidebarLabelTooltip, initialSelected, $toggleText, fieldName, useId, propertyName, showMenuAbove, $container, $dropdownContainer; $dropdown = $(dropdown); $dropdownContainer = $dropdown.closest('.labels-filter'); $toggleText = $dropdown.find('.dropdown-toggle-text'); @@ -53,13 +53,6 @@ export default class LabelsSelect { .map(function () { return this.value; }).get(); - if (issueUpdateURL != null) { - issueURLSplit = issueUpdateURL.split('/'); - } - if (issueUpdateURL) { - labelHTMLTemplate = _.template('<% _.each(labels, function(label){ %> issues?label_name[]=<%- encodeURIComponent(label.title) %>"> <%- label.title %> <% }); %>'); - labelNoneHTMLTemplate = 'None'; - } const handleClick = options.handleClick; $sidebarLabelTooltip.tooltip(); @@ -91,14 +84,17 @@ export default class LabelsSelect { $loading.fadeOut(); $dropdown.trigger('loaded.gl.dropdown'); $selectbox.hide(); - data.issueURLSplit = issueURLSplit; + data.issueUpdateURL = issueUpdateURL; labelCount = 0; - if (data.labels.length) { - template = labelHTMLTemplate(data); + if (data.labels.length && issueUpdateURL) { + template = LabelsSelect.getLabelTemplate({ + labels: data.labels, + issueUpdateURL, + }); labelCount = data.labels.length; } else { - template = labelNoneHTMLTemplate; + template = 'None'; } $value.removeAttr('style').html(template); $sidebarCollapsedValue.text(labelCount); @@ -418,6 +414,26 @@ export default class LabelsSelect { this.bindEvents(); } + static getLabelTemplate(tplData) { + // We could use ES6 template string here + // and properly indent markup for readability + // but that also introduces unintended white-space + // so best approach is to use traditional way of + // concatenation + // see: http://2ality.com/2016/05/template-literal-whitespace.html#joining-arrays + const tpl = _.template([ + '<% _.each(labels, function(label){ %>', + '?label_name[]=<%- encodeURIComponent(label.title) %>">', + '', + '<%- label.title %>', + '', + '', + '<% }); %>', + ].join('')); + + return tpl(tplData); + } + bindEvents() { return $('body').on('change', '.selected_issue', this.onSelectCheckboxIssue); } -- cgit v1.2.1 From 7768b5f3e8e8eff878a4b263c467e59bd833bb25 Mon Sep 17 00:00:00 2001 From: Kushal Pandya Date: Tue, 27 Feb 2018 15:10:29 +0530 Subject: Add changelog entry --- changelogs/unreleased/43643-fix-mr-label-filtering.yml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changelogs/unreleased/43643-fix-mr-label-filtering.yml diff --git a/changelogs/unreleased/43643-fix-mr-label-filtering.yml b/changelogs/unreleased/43643-fix-mr-label-filtering.yml new file mode 100644 index 00000000000..32a44aef243 --- /dev/null +++ b/changelogs/unreleased/43643-fix-mr-label-filtering.yml @@ -0,0 +1,5 @@ +--- +title: Enable filtering MR list based on clicked label in MR sidebar +merge_request: 17390 +author: +type: fixed -- cgit v1.2.1 From f0197d733818c17ad28a92c6f4def6221050f2e0 Mon Sep 17 00:00:00 2001 From: Kushal Pandya Date: Wed, 28 Feb 2018 16:43:10 +0530 Subject: Fix unnecessary spacing between labels --- app/assets/stylesheets/pages/issuable.scss | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 0cf67734237..4c9732c26d9 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -103,6 +103,7 @@ .issuable-show-labels { a { margin-bottom: 5px; + margin-right: 5px; display: inline-block; .color-label { @@ -116,6 +117,12 @@ } &.has-labels { + // this font size is a fix to + // prevent unintended spacing between labels + // which shows up when rendering markup has white-space + // characters present. + // see: https://css-tricks.com/fighting-the-space-between-inline-block-elements/#article-header-id-3 + font-size: 0; margin-bottom: -5px; } } -- cgit v1.2.1 From 90baab96e0c3a788e68a48a1adff7954cd88c263 Mon Sep 17 00:00:00 2001 From: Kushal Pandya Date: Wed, 28 Feb 2018 18:03:46 +0530 Subject: Labels Select Tests --- spec/javascripts/labels_select_spec.js | 43 ++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 spec/javascripts/labels_select_spec.js diff --git a/spec/javascripts/labels_select_spec.js b/spec/javascripts/labels_select_spec.js new file mode 100644 index 00000000000..b8f7b1dc855 --- /dev/null +++ b/spec/javascripts/labels_select_spec.js @@ -0,0 +1,43 @@ +import LabelsSelect from '~/labels_select'; + +const mockUrl = '/foo/bar/url'; + +const mockLabels = [ + { + id: 26, + title: 'Foo Label', + description: 'Foobar', + color: '#BADA55', + text_color: '#FFFFFF', + }, +]; + +describe('LabelsSelect', () => { + describe('getLabelTemplate', () => { + const label = mockLabels[0]; + let $labelEl; + + beforeEach(() => { + $labelEl = $(LabelsSelect.getLabelTemplate({ + labels: mockLabels, + issueUpdateURL: mockUrl, + })); + }); + + it('generated label item template has correct label URL', () => { + expect($labelEl.attr('href')).toBe('/foo/bar?label_name[]=Foo%20Label'); + }); + + it('generated label item template has correct label title', () => { + expect($labelEl.find('span.label').text()).toBe(label.title); + }); + + it('generated label item template has label description as title attribute', () => { + expect($labelEl.find('span.label').attr('title')).toBe(label.description); + }); + + it('generated label item template has correct label styles', () => { + expect($labelEl.find('span.label').attr('style')).toBe(`background-color: ${label.color}; color: ${label.text_color};`); + }); + }); +}); -- cgit v1.2.1 From 17b91050ba2a1729fc8b03da0e8c6f5de761012c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kim=20Carlb=C3=A4cker?= Date: Wed, 28 Feb 2018 13:23:26 +0000 Subject: Re-enable the remaning Wiki Gitaly-Endpoints --- lib/gitlab/git/wiki.rb | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/gitlab/git/wiki.rb b/lib/gitlab/git/wiki.rb index ac12271a87e..52b44b9b3c5 100644 --- a/lib/gitlab/git/wiki.rb +++ b/lib/gitlab/git/wiki.rb @@ -59,7 +59,7 @@ module Gitlab end def pages(limit: nil) - @repository.gitaly_migrate(:wiki_get_all_pages, status: Gitlab::GitalyClient::MigrationStatus::DISABLED) do |is_enabled| + @repository.gitaly_migrate(:wiki_get_all_pages) do |is_enabled| if is_enabled gitaly_get_all_pages else @@ -68,9 +68,8 @@ module Gitlab end end - # Disable because of https://gitlab.com/gitlab-org/gitlab-ce/issues/42039 def page(title:, version: nil, dir: nil) - @repository.gitaly_migrate(:wiki_find_page, status: Gitlab::GitalyClient::MigrationStatus::DISABLED) do |is_enabled| + @repository.gitaly_migrate(:wiki_find_page) do |is_enabled| if is_enabled gitaly_find_page(title: title, version: version, dir: dir) else -- cgit v1.2.1 From f30b47b3876faf9c171522e1e838e41f33f4c6b3 Mon Sep 17 00:00:00 2001 From: Rob Watson Date: Sun, 25 Feb 2018 14:06:09 +0100 Subject: PagesDomain: Add edit/update functionality --- .../projects/pages_domains_controller.rb | 29 ++++++++--- app/views/projects/pages_domains/_form.html.haml | 56 ++++++++++---------- app/views/projects/pages_domains/edit.html.haml | 9 ++++ app/views/projects/pages_domains/new.html.haml | 5 +- app/views/projects/pages_domains/show.html.haml | 1 + .../unreleased/feature-edit_pages_domain.yml | 5 ++ config/routes/project.rb | 2 +- .../projects/pages_domains_controller_spec.rb | 60 ++++++++++++++++++++++ spec/features/projects/pages_spec.rb | 31 +++++++++++ 9 files changed, 158 insertions(+), 40 deletions(-) create mode 100644 app/views/projects/pages_domains/edit.html.haml create mode 100644 changelogs/unreleased/feature-edit_pages_domain.yml diff --git a/app/controllers/projects/pages_domains_controller.rb b/app/controllers/projects/pages_domains_controller.rb index b71f1e5fef4..4856be61e88 100644 --- a/app/controllers/projects/pages_domains_controller.rb +++ b/app/controllers/projects/pages_domains_controller.rb @@ -3,7 +3,7 @@ class Projects::PagesDomainsController < Projects::ApplicationController before_action :require_pages_enabled! before_action :authorize_update_pages!, except: [:show] - before_action :domain, only: [:show, :destroy, :verify] + before_action :domain, except: [:new, :create] def show end @@ -24,8 +24,11 @@ class Projects::PagesDomainsController < Projects::ApplicationController redirect_to project_pages_domain_path(@project, @domain) end + def edit + end + def create - @domain = @project.pages_domains.create(pages_domain_params) + @domain = @project.pages_domains.create(create_params) if @domain.valid? redirect_to project_pages_domain_path(@project, @domain) @@ -34,6 +37,16 @@ class Projects::PagesDomainsController < Projects::ApplicationController end end + def update + if @domain.update(update_params) + redirect_to project_pages_path(@project), + status: 302, + notice: 'Domain was updated' + else + render 'edit' + end + end + def destroy @domain.destroy @@ -49,12 +62,12 @@ class Projects::PagesDomainsController < Projects::ApplicationController private - def pages_domain_params - params.require(:pages_domain).permit( - :certificate, - :key, - :domain - ) + def create_params + params.require(:pages_domain).permit(:key, :certificate, :domain) + end + + def update_params + params.require(:pages_domain).permit(:key, :certificate) end def domain diff --git a/app/views/projects/pages_domains/_form.html.haml b/app/views/projects/pages_domains/_form.html.haml index ca1b41b140a..d81b07832bb 100644 --- a/app/views/projects/pages_domains/_form.html.haml +++ b/app/views/projects/pages_domains/_form.html.haml @@ -1,34 +1,30 @@ -= form_for [@project.namespace.becomes(Namespace), @project, @domain], html: { class: 'form-horizontal fieldset-form' } do |f| - - if @domain.errors.any? - #error_explanation - .alert.alert-danger - - @domain.errors.full_messages.each do |msg| - %p= msg +- if @domain.errors.any? + #error_explanation + .alert.alert-danger + - @domain.errors.full_messages.each do |msg| + %p= msg +.form-group + = f.label :domain, class: 'control-label' do + Domain + .col-sm-10 + = f.text_field :domain, required: true, autocomplete: 'off', class: 'form-control', disabled: @domain.persisted? + +- if Gitlab.config.pages.external_https .form-group - = f.label :domain, class: 'control-label' do - Domain + = f.label :certificate, class: 'control-label' do + Certificate (PEM) .col-sm-10 - = f.text_field :domain, required: true, autocomplete: 'off', class: 'form-control' - - - if Gitlab.config.pages.external_https - .form-group - = f.label :certificate, class: 'control-label' do - Certificate (PEM) - .col-sm-10 - = f.text_area :certificate, rows: 5, class: 'form-control' - %span.help-inline Upload a certificate for your domain with all intermediates - - .form-group - = f.label :key, class: 'control-label' do - Key (PEM) - .col-sm-10 - = f.text_area :key, rows: 5, class: 'form-control' - %span.help-inline Upload a private key for your certificate - - else - .nothing-here-block - Support for custom certificates is disabled. - Ask your system's administrator to enable it. + = f.text_area :certificate, rows: 5, class: 'form-control' + %span.help-inline Upload a certificate for your domain with all intermediates - .form-actions - = f.submit 'Create New Domain', class: "btn btn-save" + .form-group + = f.label :key, class: 'control-label' do + Key (PEM) + .col-sm-10 + = f.text_area :key, rows: 5, class: 'form-control' + %span.help-inline Upload a private key for your certificate +- else + .nothing-here-block + Support for custom certificates is disabled. + Ask your system's administrator to enable it. diff --git a/app/views/projects/pages_domains/edit.html.haml b/app/views/projects/pages_domains/edit.html.haml new file mode 100644 index 00000000000..60e7ecf6984 --- /dev/null +++ b/app/views/projects/pages_domains/edit.html.haml @@ -0,0 +1,9 @@ +- page_title @domain.domain +%h3.page_title + = @domain.domain +%hr.clearfix +%div + = form_for [@project.namespace.becomes(Namespace), @project, @domain], html: { class: 'form-horizontal fieldset-form' } do |f| + = render 'form', { f: f } + .form-actions + = f.submit 'Save Changes', class: "btn btn-save" diff --git a/app/views/projects/pages_domains/new.html.haml b/app/views/projects/pages_domains/new.html.haml index e1477c71d06..63a4392e307 100644 --- a/app/views/projects/pages_domains/new.html.haml +++ b/app/views/projects/pages_domains/new.html.haml @@ -3,4 +3,7 @@ New Pages Domain %hr.clearfix %div - = render 'form' + = form_for [@project.namespace.becomes(Namespace), @project, @domain], html: { class: 'form-horizontal fieldset-form' } do |f| + = render 'form', { f: f } + .form-actions + = f.submit 'Create New Domain', class: "btn btn-save" diff --git a/app/views/projects/pages_domains/show.html.haml b/app/views/projects/pages_domains/show.html.haml index 72e9203bdb0..bcd4e1c4b05 100644 --- a/app/views/projects/pages_domains/show.html.haml +++ b/app/views/projects/pages_domains/show.html.haml @@ -8,6 +8,7 @@ %h3.page-title Pages Domain + = link_to 'Edit', edit_project_pages_domain_path(@project, @domain), class: 'btn btn-success pull-right' .table-holder %table.table diff --git a/changelogs/unreleased/feature-edit_pages_domain.yml b/changelogs/unreleased/feature-edit_pages_domain.yml new file mode 100644 index 00000000000..bd0af53296c --- /dev/null +++ b/changelogs/unreleased/feature-edit_pages_domain.yml @@ -0,0 +1,5 @@ +--- +title: 'Pages custom domain: allow update of key/certificate' +merge_request: 17376 +author: rfwatson +type: changed diff --git a/config/routes/project.rb b/config/routes/project.rb index dd1a9c3be3b..34636285c51 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -55,7 +55,7 @@ constraints(ProjectUrlConstrainer.new) do end resource :pages, only: [:show, :destroy] do - resources :domains, only: [:show, :new, :create, :destroy], controller: 'pages_domains', constraints: { id: %r{[^/]+} } do + resources :domains, except: :index, controller: 'pages_domains', constraints: { id: %r{[^/]+} } do member do post :verify end diff --git a/spec/controllers/projects/pages_domains_controller_spec.rb b/spec/controllers/projects/pages_domains_controller_spec.rb index 2192fd5cae2..83a3799e883 100644 --- a/spec/controllers/projects/pages_domains_controller_spec.rb +++ b/spec/controllers/projects/pages_domains_controller_spec.rb @@ -53,6 +53,66 @@ describe Projects::PagesDomainsController do end end + describe 'GET edit' do + it "displays the 'edit' page" do + get(:edit, request_params.merge(id: pages_domain.domain)) + + expect(response).to have_gitlab_http_status(200) + expect(response).to render_template('edit') + end + end + + describe 'PATCH update' do + before do + controller.instance_variable_set(:@domain, pages_domain) + end + + let(:pages_domain_params) do + attributes_for(:pages_domain, :with_certificate, :with_key).slice(:key, :certificate) + end + + let(:params) do + request_params.merge(id: pages_domain.domain, pages_domain: pages_domain_params) + end + + it 'updates the domain' do + expect(pages_domain) + .to receive(:update) + .with(pages_domain_params) + .and_return(true) + + patch(:update, params) + end + + it 'redirects to the project page' do + patch(:update, params) + + expect(flash[:notice]).to eq 'Domain was updated' + expect(response).to redirect_to(project_pages_path(project)) + end + + context 'the domain is invalid' do + it 'renders the edit action' do + allow(pages_domain).to receive(:update).and_return(false) + + patch(:update, params) + + expect(response).to render_template('edit') + end + end + + context 'the parameters include the domain' do + it 'renders 400 Bad Request' do + expect(pages_domain) + .to receive(:update) + .with(hash_not_including(:domain)) + .and_return(true) + + patch(:update, params.deep_merge(pages_domain: { domain: 'abc' })) + end + end + end + describe 'POST verify' do let(:params) { request_params.merge(id: pages_domain.domain) } diff --git a/spec/features/projects/pages_spec.rb b/spec/features/projects/pages_spec.rb index a96f2c186a4..2a0d235ef04 100644 --- a/spec/features/projects/pages_spec.rb +++ b/spec/features/projects/pages_spec.rb @@ -160,6 +160,37 @@ feature 'Pages' do expect(page).to have_content('my.test.domain.com') end + + describe 'updating the certificate for an existing domain' do + let!(:domain) do + create(:pages_domain, :with_key, :with_certificate, project: project) + end + + it 'allows the certificate to be updated' do + visit project_pages_path(project) + + within('#content-body') { click_link 'Details' } + click_link 'Edit' + click_button 'Save Changes' + + expect(page).to have_content('Domain was updated') + end + + context 'when the certificate is invalid' do + it 'tells the user what the problem is' do + visit project_pages_path(project) + + within('#content-body') { click_link 'Details' } + click_link 'Edit' + fill_in 'Certificate (PEM)', with: 'invalid data' + click_button 'Save Changes' + + expect(page).to have_content('Certificate must be a valid PEM certificate') + expect(page).to have_content('Certificate misses intermediates') + expect(page).to have_content("Key doesn't match the certificate") + end + end + end end end -- cgit v1.2.1 From e9ece462b9dbe5c403f6d3d7043781ce83df3fdc Mon Sep 17 00:00:00 2001 From: Rob Watson Date: Sun, 25 Feb 2018 14:12:27 +0100 Subject: PagesDomain: improve breadcrumbs --- app/views/projects/pages_domains/edit.html.haml | 2 ++ app/views/projects/pages_domains/new.html.haml | 1 + app/views/projects/pages_domains/show.html.haml | 3 +++ 3 files changed, 6 insertions(+) diff --git a/app/views/projects/pages_domains/edit.html.haml b/app/views/projects/pages_domains/edit.html.haml index 60e7ecf6984..5645a4604bf 100644 --- a/app/views/projects/pages_domains/edit.html.haml +++ b/app/views/projects/pages_domains/edit.html.haml @@ -1,3 +1,5 @@ +- add_to_breadcrumbs "Pages", project_pages_path(@project) +- breadcrumb_title @domain.domain - page_title @domain.domain %h3.page_title = @domain.domain diff --git a/app/views/projects/pages_domains/new.html.haml b/app/views/projects/pages_domains/new.html.haml index 63a4392e307..5a397c9d3c7 100644 --- a/app/views/projects/pages_domains/new.html.haml +++ b/app/views/projects/pages_domains/new.html.haml @@ -1,3 +1,4 @@ +- add_to_breadcrumbs "Pages", project_pages_path(@project) - page_title 'New Pages Domain' %h3.page_title New Pages Domain diff --git a/app/views/projects/pages_domains/show.html.haml b/app/views/projects/pages_domains/show.html.haml index bcd4e1c4b05..ba0713daee9 100644 --- a/app/views/projects/pages_domains/show.html.haml +++ b/app/views/projects/pages_domains/show.html.haml @@ -1,4 +1,7 @@ +- add_to_breadcrumbs "Pages", project_pages_path(@project) +- breadcrumb_title @domain.domain - page_title "#{@domain.domain}", 'Pages Domains' + - verification_enabled = Gitlab::CurrentSettings.pages_domain_verification_enabled? - if verification_enabled && @domain.unverified? %p.alert.alert-warning -- cgit v1.2.1 From 0a44ced9a97d6f02fda78933aae293fa3050253c Mon Sep 17 00:00:00 2001 From: saberd Date: Wed, 28 Feb 2018 14:05:54 +0000 Subject: Patch 29 --- doc/user/project/merge_requests/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/user/project/merge_requests/index.md b/doc/user/project/merge_requests/index.md index 0de89f90e21..16027744164 100644 --- a/doc/user/project/merge_requests/index.md +++ b/doc/user/project/merge_requests/index.md @@ -34,7 +34,7 @@ With **[GitLab Enterprise Edition][ee]**, you can also: - View the deployment process across projects with [Multi-Project Pipeline Graphs](https://docs.gitlab.com/ee/ci/multi_project_pipeline_graphs.html#multi-project-pipeline-graphs) (available only in GitLab Premium) - Request [approvals](https://docs.gitlab.com/ee/user/project/merge_requests/merge_request_approvals.html) from your managers (available in GitLab Starter) - [Squash and merge](https://docs.gitlab.com/ee/user/project/merge_requests/squash_and_merge.html) for a cleaner commit history (available in GitLab Starter) -- Analise the impact of your changes with [Code Quality reports](https://docs.gitlab.com/ee/user/project/merge_requests/code_quality_diff.html) (available in GitLab Starter) +- Analyze the impact of your changes with [Code Quality reports](https://docs.gitlab.com/ee/user/project/merge_requests/code_quality_diff.html) (available in GitLab Starter) ## Use cases -- cgit v1.2.1 From 5d74be216382f17b8368fe60e36c3f32f59c06cb Mon Sep 17 00:00:00 2001 From: Marcia Ramos Date: Wed, 28 Feb 2018 15:30:31 +0000 Subject: Docs: explain feature availability in GitLab.com --- doc/README.md | 54 ++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 42 insertions(+), 12 deletions(-) diff --git a/doc/README.md b/doc/README.md index 46fcb7c6baf..fb7a23e2750 100644 --- a/doc/README.md +++ b/doc/README.md @@ -1,5 +1,4 @@ --- -toc: false comments: false --- @@ -8,15 +7,9 @@ comments: false Welcome to [GitLab](https://about.gitlab.com/), a Git-based fully featured platform for software development! -GitLab offers the most scalable Git-based fully integrated platform for software development, with flexible products and subscription plans. - -With GitLab self-hosted, you deploy your own GitLab instance on-premises or on a private cloud of your choice. GitLab self-hosted is available for [free and with paid subscriptions](https://about.gitlab.com/products/): Libre, Starter, Premium, and Ultimate. - -Every feature available in Libre is also available in Starter, Premium, and Ultimate. -Starter features are also available in Premium and Ultimate, and Premium features are also -available in Ultimate. - -GitLab.com is our SaaS offering. It's hosted, managed, and administered by GitLab, with [free and paid plans](https://about.gitlab.com/gitlab-com/) for individuals and teams: Free, Bronze, Silver, and Gold. +GitLab offers the most scalable Git-based fully integrated platform for +software development, with flexible products and subscriptions. +To understand what features you have access to, check the [GitLab subscriptions](#gitlab-subscriptions) below. ## Shortcuts to GitLab's most visited docs @@ -124,8 +117,6 @@ Manage your [repositories](user/project/repository/index.md) from the UI (user i - [GitLab Integration](integration/README.md): Integrate with multiple third-party services with GitLab to allow external issue trackers and external authentication. - [Trello Power-Up](integration/trello_power_up.md): Integrate with GitLab's Trello Power-Up ----- - ## Administrator documentation [Administration documentation](administration/index.md) applies to admin users of GitLab @@ -143,3 +134,42 @@ Learn how to contribute to GitLab: - [Development](development/README.md): All styleguides and explanations how to contribute. - [Legal](legal/README.md): Contributor license agreements. - [Writing documentation](development/writing_documentation.md): Contributing to GitLab Docs. + +## GitLab subscriptions + +You have two options to use GitLab: + +- GitLab self-hosted: Install, administer, and maintain your own GitLab instance. +- GitLab.com: GitLab's SaaS offering. You don't need to install anything to use GitLab.com, +you only need to [sign up](https://gitlab.com/users/sign_in) and start using GitLab +straight away. + +### GitLab self-hosted + +With GitLab self-hosted, you deploy your own GitLab instance on-premises or on a private cloud of your choice. GitLab self-hosted is available for [free and with paid subscriptions](https://about.gitlab.com/products/): Libre, Starter, Premium, and Ultimate. + +Every feature available in Libre is also available in Starter, Premium, and Ultimate. +Starter features are also available in Premium and Ultimate, and Premium features are also +available in Ultimate. + +### GitLab.com + +GitLab.com is hosted, managed, and administered by GitLab, Inc., with +[free and paid subscriptions](https://about.gitlab.com/gitlab-com/) for individuals +and teams: Free, Bronze, Silver, and Gold. + +GitLab.com subscriptions grants access +to the same features available in GitLab self-hosted, **expect +[administration](administration/index.md) tools and settings**: + +- GitLab.com Free includes the same features available in GitLab Libre +- GitLab.com Bronze includes the same features available in GitLab Starter +- GitLab.com Silver includes the same features available in GitLab Premium +- GitLab.com Gold includes the same features available in GitLab Ultimate + +For supporting the open source community and encouraging the development of +open source projects, GitLab grants access to **Gold** features +for all GitLab.com **public** projects, regardless of the subscription. + +To know more about GitLab subscriptions and licensing, please refer to the +[GitLab Product Marketing Handbook](https://about.gitlab.com/handbook/marketing/product-marketing/#tiers). -- cgit v1.2.1