From b2592f01da50367f1c6a2acf4ef701d3a7661d36 Mon Sep 17 00:00:00 2001 From: Robert Speicher Date: Thu, 8 Dec 2016 14:39:39 +1100 Subject: Store capybara-screenshots folder as artifacts for RSpec and Spinach --- .gitlab-ci.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index e522d47d19d..475346dcd34 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -69,9 +69,11 @@ stages: - knapsack rspec "--color --format documentation" artifacts: expire_in: 31d + when: always paths: - - knapsack/ - coverage/ + - knapsack/ + - tmp/capybara/ .spinach-knapsack: &spinach-knapsack stage: test @@ -87,9 +89,11 @@ stages: - knapsack spinach "-r rerun" || retry '[[ -e tmp/spinach-rerun.txt ]] && bundle exec spinach -r rerun $(cat tmp/spinach-rerun.txt)' artifacts: expire_in: 31d + when: always paths: - - knapsack/ - coverage/ + - knapsack/ + - tmp/capybara/ # Prepare and merge knapsack tests -- cgit v1.2.1 From a61c19778180a8316321ac9ca6f84de76a6c23b3 Mon Sep 17 00:00:00 2001 From: Robert Speicher Date: Thu, 8 Dec 2016 14:45:34 +1100 Subject: Don't disable capybara-screenshot in CI environment --- features/support/capybara.rb | 9 +++------ spec/support/capybara.rb | 9 +++------ 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/features/support/capybara.rb b/features/support/capybara.rb index 47372df152d..0b6a0981a3c 100644 --- a/features/support/capybara.rb +++ b/features/support/capybara.rb @@ -1,5 +1,6 @@ require 'spinach/capybara' require 'capybara/poltergeist' +require 'capybara-screenshot/spinach' # Give CI some extra time timeout = (ENV['CI'] || ENV['CI_SERVER']) ? 90 : 15 @@ -20,12 +21,8 @@ end Capybara.default_max_wait_time = timeout Capybara.ignore_hidden_elements = false -unless ENV['CI'] || ENV['CI_SERVER'] - require 'capybara-screenshot/spinach' - - # Keep only the screenshots generated from the last failing test suite - Capybara::Screenshot.prune_strategy = :keep_last_run -end +# Keep only the screenshots generated from the last failing test suite +Capybara::Screenshot.prune_strategy = :keep_last_run Spinach.hooks.before_run do TestEnv.warm_asset_cache unless ENV['CI'] || ENV['CI_SERVER'] diff --git a/spec/support/capybara.rb b/spec/support/capybara.rb index 16d5f2bf0b8..ebb1f30f090 100644 --- a/spec/support/capybara.rb +++ b/spec/support/capybara.rb @@ -1,6 +1,7 @@ require 'capybara/rails' require 'capybara/rspec' require 'capybara/poltergeist' +require 'capybara-screenshot/rspec' # Give CI some extra time timeout = (ENV['CI'] || ENV['CI_SERVER']) ? 90 : 10 @@ -21,12 +22,8 @@ end Capybara.default_max_wait_time = timeout Capybara.ignore_hidden_elements = true -unless ENV['CI'] || ENV['CI_SERVER'] - require 'capybara-screenshot/rspec' - - # Keep only the screenshots generated from the last failing test suite - Capybara::Screenshot.prune_strategy = :keep_last_run -end +# Keep only the screenshots generated from the last failing test suite +Capybara::Screenshot.prune_strategy = :keep_last_run RSpec.configure do |config| config.before(:suite) do -- cgit v1.2.1 From 5852e0e0605e90949aec817293f45fabf5b116ac Mon Sep 17 00:00:00 2001 From: Maxime Besson Date: Fri, 24 Feb 2017 12:21:41 +0100 Subject: Suggest a more secure way of handling SSH host keys in docker builds --- doc/ci/ssh_keys/README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/doc/ci/ssh_keys/README.md b/doc/ci/ssh_keys/README.md index 49e7ac38b26..688a69d77ba 100644 --- a/doc/ci/ssh_keys/README.md +++ b/doc/ci/ssh_keys/README.md @@ -38,6 +38,15 @@ following **Settings > Variables**. As **Key** add the name `SSH_PRIVATE_KEY` and in the **Value** field paste the content of your _private_ key that you created earlier. +It is also good practice to check the server's own public key to make sure you +are not being targeted by a man-in-the-middle attack. To do this, add another +variable named `SSH_SERVER_HOSTKEYS`. To find out the hostkeys of your server, run +the `ssh-keyscan YOUR_SERVER` command from a trusted network (ideally, from the +server itself), and paste its output into the `SSH_SERVER_HOSTKEY` variable. If +you need to connect to multiple servers, concatenate all the server public keys +that you collected into the **Value** of the variable. There must be one key per +line. + Next you need to modify your `.gitlab-ci.yml` with a `before_script` action. Add it to the top: @@ -59,6 +68,11 @@ before_script: # you will overwrite your user's SSH config. - mkdir -p ~/.ssh - '[[ -f /.dockerenv ]] && echo -e "Host *\n\tStrictHostKeyChecking no\n\n" > ~/.ssh/config' + # In order to properly check the server's host key, assuming you created the + # SSH_SERVER_HOSTKEYS variable previously, uncomment the following two lines + # instead. + # - mkdir -p ~/.ssh + # - '[[ -f /.dockerenv ]] && echo "$SSH_SERVER_HOSTKEYS" > ~/.ssh/known_hosts' ``` As a final step, add the _public_ key from the one you created earlier to the -- cgit v1.2.1 From 7951b8469d81f58132f69ad3a1e71fbd39ef1f49 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Fri, 24 Feb 2017 23:11:50 +0530 Subject: Document U2F limitations with multiple hostnames/FQDNs. --- .../28277-document-u2f-limitations-with-multiple-urls.yml | 4 ++++ doc/user/profile/account/two_factor_authentication.md | 11 +++++++++++ 2 files changed, 15 insertions(+) create mode 100644 changelogs/unreleased/28277-document-u2f-limitations-with-multiple-urls.yml diff --git a/changelogs/unreleased/28277-document-u2f-limitations-with-multiple-urls.yml b/changelogs/unreleased/28277-document-u2f-limitations-with-multiple-urls.yml new file mode 100644 index 00000000000..6e3cd8a60d8 --- /dev/null +++ b/changelogs/unreleased/28277-document-u2f-limitations-with-multiple-urls.yml @@ -0,0 +1,4 @@ +--- +title: Document U2F limitations with multiple URLs +merge_request: 9300 +author: diff --git a/doc/user/profile/account/two_factor_authentication.md b/doc/user/profile/account/two_factor_authentication.md index eaa39a0c4ea..63a3d3c472e 100644 --- a/doc/user/profile/account/two_factor_authentication.md +++ b/doc/user/profile/account/two_factor_authentication.md @@ -215,3 +215,14 @@ you may have cases where authorization always fails because of time differences. [Google Authenticator]: https://support.google.com/accounts/answer/1066447?hl=en [FreeOTP]: https://freeotp.github.io/ [YubiKey]: https://www.yubico.com/products/yubikey-hardware/ + +- The GitLab U2F implementation does _not_ work when the GitLab instance is accessed from +multiple hostnames, or FQDNs. Each U2F registration is linked to the _current hostname_ at +the time of registration, and cannot be used for other hostnames/FQDNs. + + For example, if a user is trying to access a GitLab instance from `first.host.xyz` and `second.host.xyz`: + + - The user logs in via `first.host.xyz` and registers their U2F key. + - The user logs out and attempts to log in via `first.host.xyz` - U2F authentication suceeds. + - The user logs out and attempts to log in via `second.host.xyz` - U2F authentication fails, because + the U2F key has only been registered on `first.host.xyz`. -- cgit v1.2.1 From 850f19c02c53648b16a531a81586c05edcfa7530 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 7 Mar 2017 09:24:01 +0000 Subject: Added filtered search bar to issue boards Closes #28312 --- app/assets/javascripts/boards/boards_bundle.js | 3 +++ .../javascripts/boards/filtered_search_boards.js | 5 +++++ app/assets/javascripts/boards/stores/boards_store.js | 4 ++-- app/assets/stylesheets/framework/filters.scss | 5 +++++ app/views/projects/boards/_show.html.haml | 3 ++- app/views/shared/issuable/_filter.html.haml | 18 ++---------------- app/views/shared/issuable/_search_bar.html.haml | 16 ++++++++++++++-- changelogs/unreleased/issue-boards-new-search-bar.yml | 4 ++++ 8 files changed, 37 insertions(+), 21 deletions(-) create mode 100644 app/assets/javascripts/boards/filtered_search_boards.js create mode 100644 changelogs/unreleased/issue-boards-new-search-bar.yml diff --git a/app/assets/javascripts/boards/boards_bundle.js b/app/assets/javascripts/boards/boards_bundle.js index 55d13be6e5f..951cb854ce8 100644 --- a/app/assets/javascripts/boards/boards_bundle.js +++ b/app/assets/javascripts/boards/boards_bundle.js @@ -4,6 +4,7 @@ window.Vue = require('vue'); window.Vue.use(require('vue-resource')); +import FilteredSearchBoards from './filtered_search_boards'; require('./models/issue'); require('./models/label'); require('./models/list'); @@ -26,6 +27,8 @@ $(() => { const Store = gl.issueBoards.BoardsStore; const ModalStore = gl.issueBoards.ModalStore; + new FilteredSearchBoards(); + window.gl = window.gl || {}; if (gl.IssueBoardsApp) { diff --git a/app/assets/javascripts/boards/filtered_search_boards.js b/app/assets/javascripts/boards/filtered_search_boards.js new file mode 100644 index 00000000000..6a00d84faf1 --- /dev/null +++ b/app/assets/javascripts/boards/filtered_search_boards.js @@ -0,0 +1,5 @@ +export default class FilteredSearchBoards extends gl.FilteredSearchManager { + constructor() { + super('boards'); + } +} diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js index 3866c6bbfc6..c902a1d8bfc 100644 --- a/app/assets/javascripts/boards/stores/boards_store.js +++ b/app/assets/javascripts/boards/stores/boards_store.js @@ -19,8 +19,8 @@ create () { this.state.lists = []; this.state.filters = { - author_id: gl.utils.getParameterValues('author_id')[0], - assignee_id: gl.utils.getParameterValues('assignee_id')[0], + author_username: gl.utils.getParameterValues('author_username')[0], + assignee_username: gl.utils.getParameterValues('assignee_username')[0], milestone_title: gl.utils.getParameterValues('milestone_title')[0], label_name: gl.utils.getParameterValues('label_name[]'), search: '' diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss index 8f2150066c7..bf0e8e2b891 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -219,6 +219,11 @@ } } +.filter-dropdown-container { + display: -webkit-flex; + display: flex; +} + .dropdown-menu .filter-dropdown-item { padding: 0; } diff --git a/app/views/projects/boards/_show.html.haml b/app/views/projects/boards/_show.html.haml index 3ae78387938..a3593c9f5db 100644 --- a/app/views/projects/boards/_show.html.haml +++ b/app/views/projects/boards/_show.html.haml @@ -4,6 +4,7 @@ - content_for :page_specific_javascripts do = page_specific_javascript_bundle_tag('common_vue') + = page_specific_javascript_bundle_tag('filtered_search') = page_specific_javascript_bundle_tag('boards') = page_specific_javascript_bundle_tag('simulate_drag') if Rails.env.test? @@ -12,7 +13,7 @@ = render "projects/issues/head" -= render 'shared/issuable/filter', type: :boards += render 'shared/issuable/search_bar', type: :boards #board-app.boards-app{ "v-cloak" => true, data: board_data } .boards-list{ ":class" => "{ 'is-compact': detailIssueVisible }" } diff --git a/app/views/shared/issuable/_filter.html.haml b/app/views/shared/issuable/_filter.html.haml index f17ae9f28eb..f0bad69a989 100644 --- a/app/views/shared/issuable/_filter.html.haml +++ b/app/views/shared/issuable/_filter.html.haml @@ -1,4 +1,4 @@ -- finder = controller.controller_name == 'issues' || controller.controller_name == 'boards' ? issues_finder : merge_requests_finder +- finder = controller.controller_name == 'issues' ? issues_finder : merge_requests_finder - boards_page = controller.controller_name == 'boards' .issues-filters @@ -34,21 +34,7 @@ %a{ href: page_filter_path(without: issuable_filter_params) } Reset filters .pull-right - - if boards_page - #js-boards-search.issue-boards-search - %input.pull-left.form-control{ type: "search", placeholder: "Filter by name...", "v-model" => "filters.search", "debounce" => "250" } - - if can?(current_user, :admin_list, @project) - #js-add-issues-btn.pull-right.prepend-left-10 - .dropdown.pull-right - %button.btn.btn-create.btn-inverted.js-new-board-list{ type: "button", data: { toggle: "dropdown", labels: labels_filter_path, namespace_path: @project.try(:namespace).try(:path), project_path: @project.try(:path) } } - Add list - .dropdown-menu.dropdown-menu-paging.dropdown-menu-align-right.dropdown-menu-issues-board-new.dropdown-menu-selectable - = render partial: "shared/issuable/label_page_default", locals: { show_footer: true, show_create: true, show_boards_content: true, title: "Add list" } - - if can?(current_user, :admin_label, @project) - = render partial: "shared/issuable/label_page_create" - = dropdown_loading - - else - = render 'shared/sort_dropdown' + = render 'shared/sort_dropdown' - if @bulk_edit .issues_bulk_update.hide diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index 32128f3b3dc..515c3d4258e 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -85,8 +85,20 @@ %span.dropdown-label-box{ style: 'background: {{color}}' } %span.label-title.js-data-value {{title}} - .pull-right.filter-dropdown-container - = render 'shared/sort_dropdown' + .filter-dropdown-container + - if type == :boards + - if can?(current_user, :admin_list, @project) + .dropdown.prepend-left-10 + %button.btn.btn-create.btn-inverted.js-new-board-list{ type: "button", data: { toggle: "dropdown", labels: labels_filter_path, namespace_path: @project.try(:namespace).try(:path), project_path: @project.try(:path) } } + Add list + .dropdown-menu.dropdown-menu-paging.dropdown-menu-align-right.dropdown-menu-issues-board-new.dropdown-menu-selectable + = render partial: "shared/issuable/label_page_default", locals: { show_footer: true, show_create: true, show_boards_content: true, title: "Add list" } + - if can?(current_user, :admin_label, @project) + = render partial: "shared/issuable/label_page_create" + = dropdown_loading + #js-add-issues-btn.prepend-left-10 + - else + = render 'shared/sort_dropdown' - if @bulk_edit .issues_bulk_update.hide diff --git a/changelogs/unreleased/issue-boards-new-search-bar.yml b/changelogs/unreleased/issue-boards-new-search-bar.yml new file mode 100644 index 00000000000..b02be70c470 --- /dev/null +++ b/changelogs/unreleased/issue-boards-new-search-bar.yml @@ -0,0 +1,4 @@ +--- +title: Added new filtered search bar to issue boards +merge_request: +author: -- cgit v1.2.1 From f89782b3f25984794f4f9752979c05d5ed6f0a96 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 7 Mar 2017 11:05:37 +0000 Subject: Changed store Async updates the boards when searching --- app/assets/javascripts/boards/boards_bundle.js | 4 ++-- app/assets/javascripts/boards/components/board.js | 8 ++++---- .../javascripts/boards/filtered_search_boards.js | 9 ++++++++- app/assets/javascripts/boards/models/list.js | 20 ++++++++++++++++---- app/assets/javascripts/boards/stores/boards_store.js | 11 ++++------- .../filtered_search/filtered_search_manager.js | 8 ++++++-- 6 files changed, 40 insertions(+), 20 deletions(-) diff --git a/app/assets/javascripts/boards/boards_bundle.js b/app/assets/javascripts/boards/boards_bundle.js index 951cb854ce8..6b294290f77 100644 --- a/app/assets/javascripts/boards/boards_bundle.js +++ b/app/assets/javascripts/boards/boards_bundle.js @@ -27,8 +27,6 @@ $(() => { const Store = gl.issueBoards.BoardsStore; const ModalStore = gl.issueBoards.ModalStore; - new FilteredSearchBoards(); - window.gl = window.gl || {}; if (gl.IssueBoardsApp) { @@ -62,6 +60,8 @@ $(() => { }, created () { gl.boardService = new BoardService(this.endpoint, this.bulkUpdatePath, this.boardId); + + new FilteredSearchBoards(Store.filter); }, mounted () { Store.disabled = this.disabled; diff --git a/app/assets/javascripts/boards/components/board.js b/app/assets/javascripts/boards/components/board.js index 18324de18b3..30d3be453be 100644 --- a/app/assets/javascripts/boards/components/board.js +++ b/app/assets/javascripts/boards/components/board.js @@ -28,16 +28,16 @@ require('./board_list'); data () { return { detailIssue: Store.detail, - filters: Store.state.filters, + filter: Store.filter, }; }, watch: { - filters: { - handler () { + filter: { + handler() { this.list.page = 1; this.list.getIssues(true); }, - deep: true + deep: true, }, detailIssue: { handler () { diff --git a/app/assets/javascripts/boards/filtered_search_boards.js b/app/assets/javascripts/boards/filtered_search_boards.js index 6a00d84faf1..0b11237b03d 100644 --- a/app/assets/javascripts/boards/filtered_search_boards.js +++ b/app/assets/javascripts/boards/filtered_search_boards.js @@ -1,5 +1,12 @@ export default class FilteredSearchBoards extends gl.FilteredSearchManager { - constructor() { + constructor(store) { super('boards'); + + this.store = store; + this.destroyOnSubmit = false + } + + updateObject(path) { + this.store.path = path.substr(1); } } diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js index f237567208c..ae117aa3900 100644 --- a/app/assets/javascripts/boards/models/list.js +++ b/app/assets/javascripts/boards/models/list.js @@ -10,7 +10,7 @@ class List { this.title = obj.title; this.type = obj.list_type; this.preset = ['done', 'blank'].indexOf(this.type) > -1; - this.filters = gl.issueBoards.BoardsStore.state.filters; + this.filterPath = gl.issueBoards.BoardsStore.filter.path; this.page = 1; this.loading = true; this.loadingMore = false; @@ -65,12 +65,24 @@ class List { } getIssues (emptyIssues = true) { - const filters = this.filters; const data = { page: this.page }; + gl.issueBoards.BoardsStore.filter.path.split('&').forEach((filterParam) => { + const paramSplit = filterParam.split('='); + const paramKeyNormalized = paramSplit[0].replace('[]', ''); + const isArray = paramSplit[0].indexOf('[]'); + + if (isArray >= 0) { + if (!data[paramKeyNormalized]) { + data[paramKeyNormalized] = []; + } - Object.keys(filters).forEach((key) => { data[key] = filters[key]; }); + data[paramKeyNormalized].push(paramSplit[1]); + } else { + data[paramKeyNormalized] = paramSplit[1]; + } + }); - if (this.label) { + if (this.label && data.label_name) { data.label_name = data.label_name.filter(label => label !== this.label.title); } diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js index c902a1d8bfc..d7e3973b327 100644 --- a/app/assets/javascripts/boards/stores/boards_store.js +++ b/app/assets/javascripts/boards/stores/boards_store.js @@ -8,6 +8,9 @@ gl.issueBoards.BoardsStore = { disabled: false, + filter: { + path: '', + }, state: {}, detail: { issue: {} @@ -18,13 +21,7 @@ }, create () { this.state.lists = []; - this.state.filters = { - author_username: gl.utils.getParameterValues('author_username')[0], - assignee_username: gl.utils.getParameterValues('assignee_username')[0], - milestone_title: gl.utils.getParameterValues('milestone_title')[0], - label_name: gl.utils.getParameterValues('label_name[]'), - search: '' - }; + this.filter.path = gl.utils.getUrlParamsArray().join('&'); }, addList (listObj) { const list = new List(listObj); diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index 58a984048de..56ff091197c 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -106,7 +106,7 @@ if (!activeElements.length) { // Prevent droplab from opening dropdown - this.dropdownManager.destroyDroplab(); + //this.dropdownManager.destroyDroplab(); this.search(); } @@ -345,7 +345,11 @@ const parameterizedUrl = `?scope=all&utf8=✓&${paths.join('&')}`; - gl.utils.visitUrl(parameterizedUrl); + if (this.updateObject) { + this.updateObject(parameterizedUrl); + } else { + gl.utils.visitUrl(parameterizedUrl); + } } getUsernameParams() { -- cgit v1.2.1 From ddf71fcef5d0d7b9952d77d712007008efbb5d3f Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 7 Mar 2017 11:07:26 +0000 Subject: Updates the URL --- app/assets/javascripts/boards/filtered_search_boards.js | 1 + app/assets/javascripts/boards/stores/boards_store.js | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/boards/filtered_search_boards.js b/app/assets/javascripts/boards/filtered_search_boards.js index 0b11237b03d..ff8da88e6e8 100644 --- a/app/assets/javascripts/boards/filtered_search_boards.js +++ b/app/assets/javascripts/boards/filtered_search_boards.js @@ -8,5 +8,6 @@ export default class FilteredSearchBoards extends gl.FilteredSearchManager { updateObject(path) { this.store.path = path.substr(1); + gl.issueBoards.BoardsStore.updateFiltersUrl(); } } diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js index d7e3973b327..28ecb322df7 100644 --- a/app/assets/javascripts/boards/stores/boards_store.js +++ b/app/assets/javascripts/boards/stores/boards_store.js @@ -120,7 +120,7 @@ })[0]; }, updateFiltersUrl () { - history.pushState(null, null, `?${$.param(this.state.filters)}`); + history.pushState(null, null, `?${this.filter.path}`); } }; })(); -- cgit v1.2.1 From 107c39a66e621e35f808b3a257789d78bf153894 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 7 Mar 2017 14:28:50 +0000 Subject: Stop droplab from destroying itself is handled async --- app/assets/javascripts/boards/filtered_search_boards.js | 3 ++- .../javascripts/filtered_search/filtered_search_manager.js | 10 ++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/app/assets/javascripts/boards/filtered_search_boards.js b/app/assets/javascripts/boards/filtered_search_boards.js index ff8da88e6e8..43c6d9d7237 100644 --- a/app/assets/javascripts/boards/filtered_search_boards.js +++ b/app/assets/javascripts/boards/filtered_search_boards.js @@ -3,11 +3,12 @@ export default class FilteredSearchBoards extends gl.FilteredSearchManager { super('boards'); this.store = store; - this.destroyOnSubmit = false + this.isHandledAsync = true; } updateObject(path) { this.store.path = path.substr(1); + gl.issueBoards.BoardsStore.updateFiltersUrl(); } } diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index 56ff091197c..652d6c9be0e 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -105,8 +105,14 @@ e.preventDefault(); if (!activeElements.length) { - // Prevent droplab from opening dropdown - //this.dropdownManager.destroyDroplab(); + if (this.isHandledAsync) { + e.stopImmediatePropagation(); + this.filteredSearchInput.blur(); + this.dropdownManager.resetDropdowns(); + } else { + // Prevent droplab from opening dropdown + this.dropdownManager.destroyDroplab(); + } this.search(); } -- cgit v1.2.1 From ab7bfff08b2ba8d15f1ab5f8fa4449dc53f51bab Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 7 Mar 2017 14:43:17 +0000 Subject: Make changing the URL optional - future proof ourselves for the modal window --- app/assets/javascripts/boards/boards_bundle.js | 7 ++++--- app/assets/javascripts/boards/filtered_search_boards.js | 7 +++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/app/assets/javascripts/boards/boards_bundle.js b/app/assets/javascripts/boards/boards_bundle.js index 6b294290f77..1731f218f37 100644 --- a/app/assets/javascripts/boards/boards_bundle.js +++ b/app/assets/javascripts/boards/boards_bundle.js @@ -1,10 +1,11 @@ -/* eslint-disable one-var, quote-props, comma-dangle, space-before-function-paren */ +/* eslint-disable one-var, quote-props, comma-dangle, space-before-function-paren, no-new */ /* global Vue */ /* global BoardService */ +import FilteredSearchBoards from './filtered_search_boards'; + window.Vue = require('vue'); window.Vue.use(require('vue-resource')); -import FilteredSearchBoards from './filtered_search_boards'; require('./models/issue'); require('./models/label'); require('./models/list'); @@ -61,7 +62,7 @@ $(() => { created () { gl.boardService = new BoardService(this.endpoint, this.bulkUpdatePath, this.boardId); - new FilteredSearchBoards(Store.filter); + new FilteredSearchBoards(Store.filter, true); }, mounted () { Store.disabled = this.disabled; diff --git a/app/assets/javascripts/boards/filtered_search_boards.js b/app/assets/javascripts/boards/filtered_search_boards.js index 43c6d9d7237..d00cb123909 100644 --- a/app/assets/javascripts/boards/filtered_search_boards.js +++ b/app/assets/javascripts/boards/filtered_search_boards.js @@ -1,14 +1,17 @@ export default class FilteredSearchBoards extends gl.FilteredSearchManager { - constructor(store) { + constructor(store, updateUrl = false) { super('boards'); this.store = store; + this.updateUrl = updateUrl; this.isHandledAsync = true; } updateObject(path) { this.store.path = path.substr(1); - gl.issueBoards.BoardsStore.updateFiltersUrl(); + if (this.updateUrl) { + gl.issueBoards.BoardsStore.updateFiltersUrl(); + } } } -- cgit v1.2.1 From 382fea7b5925ac7dc47ccfd79f7537284e68cd6f Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 7 Mar 2017 15:54:45 +0000 Subject: Handle clear search async --- app/assets/javascripts/filtered_search/filtered_search_manager.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index 652d6c9be0e..3478f1130a5 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -107,6 +107,7 @@ if (!activeElements.length) { if (this.isHandledAsync) { e.stopImmediatePropagation(); + this.filteredSearchInput.blur(); this.dropdownManager.resetDropdowns(); } else { @@ -205,6 +206,10 @@ this.handleInputPlaceholder(); this.dropdownManager.resetDropdowns(); + + if (this.isHandledAsync) { + this.search(); + } } handleInputVisualToken() { -- cgit v1.2.1 From 809bba7d02b45938494f8ae471a2b27ce4a40833 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Wed, 8 Mar 2017 12:17:01 +0000 Subject: Updated specs --- app/assets/javascripts/boards/boards_bundle.js | 4 +- .../boards/components/issue_card_inner.js | 23 ++-- .../javascripts/boards/filtered_search_boards.js | 14 ++ app/assets/javascripts/boards/models/list.js | 8 +- app/assets/stylesheets/framework/filters.scss | 1 - app/views/shared/issuable/_search_bar.html.haml | 2 +- spec/features/boards/add_issues_modal_spec.rb | 2 +- spec/features/boards/boards_spec.rb | 153 ++++++++------------- spec/features/issuables/default_sort_order_spec.rb | 2 +- 9 files changed, 86 insertions(+), 123 deletions(-) diff --git a/app/assets/javascripts/boards/boards_bundle.js b/app/assets/javascripts/boards/boards_bundle.js index 1731f218f37..9e9da7dfac4 100644 --- a/app/assets/javascripts/boards/boards_bundle.js +++ b/app/assets/javascripts/boards/boards_bundle.js @@ -62,7 +62,7 @@ $(() => { created () { gl.boardService = new BoardService(this.endpoint, this.bulkUpdatePath, this.boardId); - new FilteredSearchBoards(Store.filter, true); + gl.boardsFilterManager = new FilteredSearchBoards(Store.filter, true); }, mounted () { Store.disabled = this.disabled; @@ -85,7 +85,7 @@ $(() => { }); gl.IssueBoardsSearch = new Vue({ - el: document.getElementById('js-boards-search'), + el: document.getElementById('js-add-list'), data: { filters: Store.state.filters }, diff --git a/app/assets/javascripts/boards/components/issue_card_inner.js b/app/assets/javascripts/boards/components/issue_card_inner.js index 22a8b971ff8..dce573ed6ca 100644 --- a/app/assets/javascripts/boards/components/issue_card_inner.js +++ b/app/assets/javascripts/boards/components/issue_card_inner.js @@ -31,29 +31,22 @@ return !this.list.label || label.id !== this.list.label.id; }, filterByLabel(label, e) { - let labelToggleText = label.title; - const labelIndex = Store.state.filters.label_name.indexOf(label.title); + const filterPath = gl.issueBoards.BoardsStore.filter.path.split('&'); + const labelTitle = encodeURIComponent(label.title); + const param = `label_name[]=${labelTitle}`; + const labelIndex = filterPath.indexOf(param); $(e.currentTarget).tooltip('hide'); if (labelIndex === -1) { - Store.state.filters.label_name.push(label.title); - $('.labels-filter').prepend(``); + filterPath.push(param); } else { - Store.state.filters.label_name.splice(labelIndex, 1); - labelToggleText = Store.state.filters.label_name[0]; - $(`.labels-filter input[name="label_name[]"][value="${label.title}"]`).remove(); + filterPath.splice(labelIndex, 1); } - const selectedLabels = Store.state.filters.label_name; - if (selectedLabels.length === 0) { - labelToggleText = 'Label'; - } else if (selectedLabels.length > 1) { - labelToggleText = `${selectedLabels[0]} + ${selectedLabels.length - 1} more`; - } - - $('.labels-filter .dropdown-toggle-text').text(labelToggleText); + gl.issueBoards.BoardsStore.filter.path = filterPath.join('&'); Store.updateFiltersUrl(); + gl.boardsFilterManager.updateTokens(); }, labelStyle(label) { return { diff --git a/app/assets/javascripts/boards/filtered_search_boards.js b/app/assets/javascripts/boards/filtered_search_boards.js index d00cb123909..3014557c440 100644 --- a/app/assets/javascripts/boards/filtered_search_boards.js +++ b/app/assets/javascripts/boards/filtered_search_boards.js @@ -14,4 +14,18 @@ export default class FilteredSearchBoards extends gl.FilteredSearchManager { gl.issueBoards.BoardsStore.updateFiltersUrl(); } } + + updateTokens() { + const tokens = document.querySelectorAll('.js-visual-token'); + + // Remove all the tokens as they will be replaced by the search manager + [].forEach.call(tokens, (el) => { + el.parentNode.removeChild(el); + }); + + this.loadSearchParamsFromURL(); + + // Get the placeholder back if search is empty + this.filteredSearchInput.dispatchEvent(new Event('input')); + } } diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js index ae117aa3900..b246c3c1503 100644 --- a/app/assets/javascripts/boards/models/list.js +++ b/app/assets/javascripts/boards/models/list.js @@ -10,7 +10,6 @@ class List { this.title = obj.title; this.type = obj.list_type; this.preset = ['done', 'blank'].indexOf(this.type) > -1; - this.filterPath = gl.issueBoards.BoardsStore.filter.path; this.page = 1; this.loading = true; this.loadingMore = false; @@ -67,18 +66,20 @@ class List { getIssues (emptyIssues = true) { const data = { page: this.page }; gl.issueBoards.BoardsStore.filter.path.split('&').forEach((filterParam) => { + if (filterParam === '') return; const paramSplit = filterParam.split('='); const paramKeyNormalized = paramSplit[0].replace('[]', ''); const isArray = paramSplit[0].indexOf('[]'); + const value = decodeURIComponent(paramSplit[1]); if (isArray >= 0) { if (!data[paramKeyNormalized]) { data[paramKeyNormalized] = []; } - data[paramKeyNormalized].push(paramSplit[1]); + data[paramKeyNormalized].push(value); } else { - data[paramKeyNormalized] = paramSplit[1]; + data[paramKeyNormalized] = value; } }); @@ -101,6 +102,7 @@ class List { } this.createIssues(data.issues); + console.log(this.issues.length); }); } diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss index bf0e8e2b891..dd2daa4b872 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -156,7 +156,6 @@ width: 100%; border: 1px solid $border-color; background-color: $white-light; - max-width: 87%; @media (max-width: $screen-xs-min) { -webkit-flex: 1 1 100%; diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index 515c3d4258e..d73556114d8 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -88,7 +88,7 @@ .filter-dropdown-container - if type == :boards - if can?(current_user, :admin_list, @project) - .dropdown.prepend-left-10 + .dropdown.prepend-left-10#js-add-list %button.btn.btn-create.btn-inverted.js-new-board-list{ type: "button", data: { toggle: "dropdown", labels: labels_filter_path, namespace_path: @project.try(:namespace).try(:path), project_path: @project.try(:path) } } Add list .dropdown-menu.dropdown-menu-paging.dropdown-menu-align-right.dropdown-menu-issues-board-new.dropdown-menu-selectable diff --git a/spec/features/boards/add_issues_modal_spec.rb b/spec/features/boards/add_issues_modal_spec.rb index a3e24bb5ffa..f7f2d883d2f 100644 --- a/spec/features/boards/add_issues_modal_spec.rb +++ b/spec/features/boards/add_issues_modal_spec.rb @@ -51,7 +51,7 @@ describe 'Issue Boards add issue modal', :feature, :js do end it 'does not show tooltip on add issues button' do - button = page.find('.issue-boards-search button', text: 'Add issues') + button = page.find('.filter-dropdown-container button', text: 'Add issues') expect(button[:title]).not_to eq("Please add a list to your board first") end diff --git a/spec/features/boards/boards_spec.rb b/spec/features/boards/boards_spec.rb index ecc356f2505..e11ba10c80c 100644 --- a/spec/features/boards/boards_spec.rb +++ b/spec/features/boards/boards_spec.rb @@ -359,17 +359,9 @@ describe 'Issue Boards', feature: true, js: true do context 'filtering' do it 'filters by author' do - page.within '.issues-filters' do - click_button('Author') - wait_for_ajax - - page.within '.dropdown-menu-author' do - click_link(user2.name) - end - wait_for_vue_resource - - expect(find('.js-author-search')).to have_content(user2.name) - end + set_filter("author", user2.username) + click_filter_link(user2.username) + submit_filter wait_for_vue_resource wait_for_board_cards(1, 1) @@ -377,17 +369,9 @@ describe 'Issue Boards', feature: true, js: true do end it 'filters by assignee' do - page.within '.issues-filters' do - click_button('Assignee') - wait_for_ajax - - page.within '.dropdown-menu-assignee' do - click_link(user.name) - end - wait_for_vue_resource - - expect(find('.js-assignee-search')).to have_content(user.name) - end + set_filter("assignee", user.username) + click_filter_link(user.username) + submit_filter wait_for_vue_resource @@ -396,17 +380,9 @@ describe 'Issue Boards', feature: true, js: true do end it 'filters by milestone' do - page.within '.issues-filters' do - click_button('Milestone') - wait_for_ajax - - page.within '.milestone-filter' do - click_link(milestone.title) - end - wait_for_vue_resource - - expect(find('.js-milestone-select')).to have_content(milestone.title) - end + set_filter("milestone", "\"#{milestone.title}\"") + click_filter_link(milestone.title) + submit_filter wait_for_vue_resource wait_for_board_cards(1, 1) @@ -415,16 +391,9 @@ describe 'Issue Boards', feature: true, js: true do end it 'filters by label' do - page.within '.issues-filters' do - click_button('Label') - wait_for_ajax - - page.within '.dropdown-menu-labels' do - click_link(testing.title) - wait_for_vue_resource - find('.dropdown-menu-close').click - end - end + set_filter("label", testing.title) + click_filter_link(testing.title) + submit_filter wait_for_vue_resource wait_for_board_cards(1, 1) @@ -432,19 +401,14 @@ describe 'Issue Boards', feature: true, js: true do end it 'filters by label with space after reload' do - page.within '.issues-filters' do - click_button('Label') - wait_for_ajax - - page.within '.dropdown-menu-labels' do - click_link(accepting.title) - wait_for_vue_resource(spinner: false) - find('.dropdown-menu-close').click - end - end + set_filter("label", "\"#{accepting.title}\"") + click_filter_link(accepting.title) + submit_filter # Test after reload page.evaluate_script 'window.location.reload()' + wait_for_board_cards(1, 1) + wait_for_empty_boards((2..3)) wait_for_vue_resource @@ -460,26 +424,16 @@ describe 'Issue Boards', feature: true, js: true do end it 'removes filtered labels' do - wait_for_vue_resource - - page.within '.labels-filter' do - click_button('Label') - wait_for_ajax - - page.within '.dropdown-menu-labels' do - click_link(testing.title) - wait_for_vue_resource(spinner: false) - end + set_filter("label", testing.title) + click_filter_link(testing.title) + submit_filter - expect(page).to have_css('input[name="label_name[]"]', visible: false) + wait_for_board_cards(1, 1) - page.within '.dropdown-menu-labels' do - click_link(testing.title) - wait_for_vue_resource(spinner: false) - end + find('.clear-search').click + submit_filter - expect(page).not_to have_css('input[name="label_name[]"]', visible: false) - end + wait_for_board_cards(1, 8) end it 'infinite scrolls list with label filter' do @@ -487,16 +441,9 @@ describe 'Issue Boards', feature: true, js: true do create(:labeled_issue, project: project, labels: [planning, testing]) end - page.within '.issues-filters' do - click_button('Label') - wait_for_ajax - - page.within '.dropdown-menu-labels' do - click_link(testing.title) - wait_for_vue_resource - find('.dropdown-menu-close').click - end - end + set_filter("label", testing.title) + click_filter_link(testing.title) + submit_filter wait_for_vue_resource @@ -518,18 +465,13 @@ describe 'Issue Boards', feature: true, js: true do end it 'filters by multiple labels' do - page.within '.issues-filters' do - click_button('Label') - wait_for_ajax + set_filter("label", testing.title) + click_filter_link(testing.title) - page.within(find('.dropdown-menu-labels')) do - click_link(testing.title) - wait_for_vue_resource - click_link(bug.title) - wait_for_vue_resource - find('.dropdown-menu-close').click - end - end + set_filter("label", bug.title) + click_filter_link(bug.title) + + submit_filter wait_for_vue_resource @@ -545,14 +487,14 @@ describe 'Issue Boards', feature: true, js: true do wait_for_vue_resource end + page.within('.tokens-container') do + expect(page).to have_content(bug.title) + end + wait_for_vue_resource wait_for_board_cards(1, 1) wait_for_empty_boards((2..3)) - - page.within('.labels-filter') do - expect(find('.dropdown-toggle-text')).to have_content(bug.title) - end end it 'removes label filter by clicking label button on issue' do @@ -560,16 +502,13 @@ describe 'Issue Boards', feature: true, js: true do page.within(find('.card', match: :first)) do click_button(bug.title) end + wait_for_vue_resource expect(page).to have_selector('.card', count: 1) end wait_for_vue_resource - - page.within('.labels-filter') do - expect(find('.dropdown-toggle-text')).to have_content(bug.title) - end end end end @@ -643,4 +582,20 @@ describe 'Issue Boards', feature: true, js: true do wait_for_board_cards(board, 0) end end + + def set_filter(type, text) + find('.filtered-search').native.send_keys("#{type}:#{text}") + end + + def submit_filter + find('.filtered-search').native.send_keys(:enter) + end + + def click_filter_link(link_text) + page.within('.filtered-search-input-container') do + expect(page).to have_button(link_text) + + click_button(link_text) + end + end end diff --git a/spec/features/issuables/default_sort_order_spec.rb b/spec/features/issuables/default_sort_order_spec.rb index 73553f97d6f..bfe43bff10f 100644 --- a/spec/features/issuables/default_sort_order_spec.rb +++ b/spec/features/issuables/default_sort_order_spec.rb @@ -176,7 +176,7 @@ describe 'Projects > Issuables > Default sort order', feature: true do end def selected_sort_order - find('.pull-right .dropdown button').text.downcase + find('.filter-dropdown-container .dropdown button').text.downcase end def visit_merge_requests_with_state(project, state) -- cgit v1.2.1 From a12b99a7698c851ddb5ea91916e19241fb189ced Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Wed, 8 Mar 2017 12:21:07 +0000 Subject: Fixed eslint errors --- app/assets/javascripts/boards/boards_bundle.js | 2 +- app/assets/javascripts/boards/models/list.js | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/app/assets/javascripts/boards/boards_bundle.js b/app/assets/javascripts/boards/boards_bundle.js index 9e9da7dfac4..2fd1f43f02c 100644 --- a/app/assets/javascripts/boards/boards_bundle.js +++ b/app/assets/javascripts/boards/boards_bundle.js @@ -1,4 +1,4 @@ -/* eslint-disable one-var, quote-props, comma-dangle, space-before-function-paren, no-new */ +/* eslint-disable one-var, quote-props, comma-dangle, space-before-function-paren */ /* global Vue */ /* global BoardService */ diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js index b246c3c1503..c2af3bb881c 100644 --- a/app/assets/javascripts/boards/models/list.js +++ b/app/assets/javascripts/boards/models/list.js @@ -102,7 +102,6 @@ class List { } this.createIssues(data.issues); - console.log(this.issues.length); }); } -- cgit v1.2.1 From 9ef84008d65dcdb5a9e2d83e7a0c053044fc91f7 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Wed, 8 Mar 2017 14:00:28 +0000 Subject: Hides on mobile --- app/views/projects/boards/_show.html.haml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/views/projects/boards/_show.html.haml b/app/views/projects/boards/_show.html.haml index a3593c9f5db..fa463edd526 100644 --- a/app/views/projects/boards/_show.html.haml +++ b/app/views/projects/boards/_show.html.haml @@ -13,7 +13,8 @@ = render "projects/issues/head" -= render 'shared/issuable/search_bar', type: :boards +.hidden-xs.hidden-sm + = render 'shared/issuable/search_bar', type: :boards #board-app.boards-app{ "v-cloak" => true, data: board_data } .boards-list{ ":class" => "{ 'is-compact': detailIssueVisible }" } -- cgit v1.2.1 From d701b39db9d458919976249a4b7c8bb5597b3606 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Wed, 8 Mar 2017 14:46:46 +0000 Subject: Fixed up boards filter spec due to CSS classes changing Also fixed issue with Vue resource encoding + in search term --- app/assets/javascripts/boards/models/list.js | 3 ++- spec/features/boards/boards_spec.rb | 14 ++++++-------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js index c2af3bb881c..ad968d2120f 100644 --- a/app/assets/javascripts/boards/models/list.js +++ b/app/assets/javascripts/boards/models/list.js @@ -70,7 +70,8 @@ class List { const paramSplit = filterParam.split('='); const paramKeyNormalized = paramSplit[0].replace('[]', ''); const isArray = paramSplit[0].indexOf('[]'); - const value = decodeURIComponent(paramSplit[1]); + let value = decodeURIComponent(paramSplit[1]); + value = value.replace(/\+/g, ' '); if (isArray >= 0) { if (!data[paramKeyNormalized]) { diff --git a/spec/features/boards/boards_spec.rb b/spec/features/boards/boards_spec.rb index e11ba10c80c..f7e8b78b54d 100644 --- a/spec/features/boards/boards_spec.rb +++ b/spec/features/boards/boards_spec.rb @@ -29,7 +29,7 @@ describe 'Issue Boards', feature: true, js: true do end it 'shows tooltip on add issues button' do - button = page.find('.issue-boards-search button', text: 'Add issues') + button = page.find('.filter-dropdown-container button', text: 'Add issues') expect(button[:"data-original-title"]).to eq("Please add a list to your board first") end @@ -115,9 +115,8 @@ describe 'Issue Boards', feature: true, js: true do end it 'search done list' do - page.within('#js-boards-search') do - find('.form-control').set(issue8.title) - end + find('.filtered-search').set(issue8.title) + find('.filtered-search').native.send_keys(:enter) wait_for_vue_resource @@ -127,9 +126,8 @@ describe 'Issue Boards', feature: true, js: true do end it 'search list' do - page.within('#js-boards-search') do - find('.form-control').set(issue5.title) - end + find('.filtered-search').set(issue5.title) + find('.filtered-search').native.send_keys(:enter) wait_for_vue_resource @@ -333,7 +331,7 @@ describe 'Issue Boards', feature: true, js: true do wait_for_vue_resource - expect(find('.issue-boards-search')).to have_selector('.open') + expect(page).to have_css('#js-add-list.open') end it 'creates new list from a new label' do -- cgit v1.2.1 From 236d6595edd2393f4ba4faadd39529fcabe48aec Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Wed, 8 Mar 2017 14:53:18 +0000 Subject: Removed previous filter code --- app/assets/javascripts/labels_select.js | 18 ++---------------- app/assets/javascripts/milestone_select.js | 8 +------- app/assets/javascripts/users_select.js | 5 ----- 3 files changed, 3 insertions(+), 28 deletions(-) diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js index 9e2d14c7f87..c648a0f076c 100644 --- a/app/assets/javascripts/labels_select.js +++ b/app/assets/javascripts/labels_select.js @@ -353,31 +353,17 @@ return; } - if ($('html').hasClass('issue-boards-page') && !$dropdown.hasClass('js-issue-board-sidebar') && - !$dropdown.closest('.add-issues-modal').length) { - boardsModel = gl.issueBoards.BoardsStore.state.filters; - } else if ($dropdown.closest('.add-issues-modal').length) { + if ($dropdown.closest('.add-issues-modal').length) { boardsModel = gl.issueBoards.ModalStore.store.filter; } if (boardsModel) { if (label.isAny) { boardsModel['label_name'] = []; - } - else if ($el.hasClass('is-active')) { + } else if ($el.hasClass('is-active')) { boardsModel['label_name'].push(label.title); } - else { - var filters = boardsModel['label_name']; - filters = filters.filter(function (filteredLabel) { - return filteredLabel !== label.title; - }); - boardsModel['label_name'] = filters; - } - if (!$dropdown.closest('.add-issues-modal').length) { - gl.issueBoards.BoardsStore.updateFiltersUrl(); - } e.preventDefault(); return; } diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js index 51fa5c828b3..4c4f94cb9f3 100644 --- a/app/assets/javascripts/milestone_select.js +++ b/app/assets/javascripts/milestone_select.js @@ -124,18 +124,12 @@ return; } - if ($('html').hasClass('issue-boards-page') && !$dropdown.hasClass('js-issue-board-sidebar') && - !$dropdown.closest('.add-issues-modal').length) { - boardsStore = gl.issueBoards.BoardsStore.state.filters; - } else if ($dropdown.closest('.add-issues-modal').length) { + if ($dropdown.closest('.add-issues-modal').length) { boardsStore = gl.issueBoards.ModalStore.store.filter; } if (boardsStore) { boardsStore[$dropdown.data('field-name')] = selected.name; - if (!$dropdown.closest('.add-issues-modal').length) { - gl.issueBoards.BoardsStore.updateFiltersUrl(); - } e.preventDefault(); } else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) { if (selected.name != null) { diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js index 27af859f7d8..c7a57b47834 100644 --- a/app/assets/javascripts/users_select.js +++ b/app/assets/javascripts/users_select.js @@ -217,11 +217,6 @@ } if ($el.closest('.add-issues-modal').length) { gl.issueBoards.ModalStore.store.filter[$dropdown.data('field-name')] = user.id; - } else if ($('html').hasClass('issue-boards-page') && !$dropdown.hasClass('js-issue-board-sidebar')) { - selectedId = user.id; - gl.issueBoards.BoardsStore.state.filters[$dropdown.data('field-name')] = user.id; - gl.issueBoards.BoardsStore.updateFiltersUrl(); - e.preventDefault(); } else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) { selectedId = user.id; return Issuable.filterResults($dropdown.closest('form')); -- cgit v1.2.1 From 75e78f108f850fe6c70c04a13747d2c40a511774 Mon Sep 17 00:00:00 2001 From: Nick Thomas Date: Wed, 8 Mar 2017 16:45:59 +0000 Subject: The GitLab Pages external-http and external-https arguments can be specified multiple times --- config/gitlab.yml.example | 4 ++-- config/initializers/1_settings.rb | 4 ++-- doc/administration/pages/index.md | 27 ++++++++++++++++----------- features/steps/project/pages.rb | 6 +++--- lib/support/init.d/gitlab.default.example | 4 ++-- 5 files changed, 25 insertions(+), 20 deletions(-) diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example index 720df0cac2d..8d0ea603569 100644 --- a/config/gitlab.yml.example +++ b/config/gitlab.yml.example @@ -157,8 +157,8 @@ production: &base host: example.com port: 80 # Set to 443 if you serve the pages with HTTPS https: false # Set to true if you serve the pages with HTTPS - # external_http: "1.1.1.1:80" # If defined, enables custom domain support in GitLab Pages - # external_https: "1.1.1.1:443" # If defined, enables custom domain and certificate support in GitLab Pages + # external_http: ["1.1.1.1:80", "[2001::1]:80"] # If defined, enables custom domain support in GitLab Pages + # external_https: ["1.1.1.1:443", "[2001::1]:443"] # If defined, enables custom domain and certificate support in GitLab Pages ## Mattermost ## For enabling Add to Mattermost button diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb index b45d0e23080..e5e90031871 100644 --- a/config/initializers/1_settings.rb +++ b/config/initializers/1_settings.rb @@ -278,8 +278,8 @@ Settings.pages['host'] ||= "example.com" Settings.pages['port'] ||= Settings.pages.https ? 443 : 80 Settings.pages['protocol'] ||= Settings.pages.https ? "https" : "http" Settings.pages['url'] ||= Settings.send(:build_pages_url) -Settings.pages['external_http'] ||= false if Settings.pages['external_http'].nil? -Settings.pages['external_https'] ||= false if Settings.pages['external_https'].nil? +Settings.pages['external_http'] ||= false unless Settings.pages['external_http'].present? +Settings.pages['external_https'] ||= false unless Settings.pages['external_https'].present? # # Git LFS diff --git a/doc/administration/pages/index.md b/doc/administration/pages/index.md index 62b0468da79..0c63b0b59a7 100644 --- a/doc/administration/pages/index.md +++ b/doc/administration/pages/index.md @@ -26,9 +26,9 @@ it works. --- -In the case of [custom domains](#custom-domains) (but not -[wildcard domains](#wildcard-domains)), the Pages daemon needs to listen on -ports `80` and/or `443`. For that reason, there is some flexibility in the way +In the case of [custom domains](#custom-domains) (but not +[wildcard domains](#wildcard-domains)), the Pages daemon needs to listen on +ports `80` and/or `443`. For that reason, there is some flexibility in the way which you can set it up: 1. Run the Pages daemon in the same server as GitLab, listening on a secondary IP. @@ -65,11 +65,13 @@ you need to add a [wildcard DNS A record][wiki-wildcard-dns] pointing to the host that GitLab runs. For example, an entry would look like this: ``` -*.example.io. 1800 IN A 1.1.1.1 +*.example.io. 1800 IN A 1.1.1.1 +*.example.io. 1800 IN AAAA 2001::1 ``` where `example.io` is the domain under which GitLab Pages will be served -and `1.1.1.1` is the IP address of your GitLab instance. +and `1.1.1.1` is the IPv4 address of your GitLab instance and `2001::1` is the +IPv6 address. If you don't have IPv6, you can omit the AAAA record. > **Note:** You should not use the GitLab domain to serve user pages. For more information @@ -141,7 +143,8 @@ outside world. In addition to the wildcard domains, you can also have the option to configure GitLab Pages to work with custom domains. Again, there are two options here: support custom domains with and without TLS certificates. The easiest setup is -that without TLS certificates. +that without TLS certificates. In either case, you'll need a secondary IP. If +you have IPv6 as well as IPv4 addresses, you can use them both. ### Custom domains @@ -163,11 +166,12 @@ world. Custom domains are supported, but no TLS. pages_external_url "http://example.io" nginx['listen_addresses'] = ['1.1.1.1'] pages_nginx['enable'] = false - gitlab_pages['external_http'] = '1.1.1.2:80' + gitlab_pages['external_http'] = ['1.1.1.2:80', '[2001::2]:80'] ``` where `1.1.1.1` is the primary IP address that GitLab is listening to and - `1.1.1.2` the secondary IP where the GitLab Pages daemon listens to. + `1.1.1.2` and `2001::2` are the secondary IPs the GitLab Pages daemon + listens on. If you don't have IPv6, you can omit the IPv6 address. 1. [Reconfigure GitLab][reconfigure] @@ -194,12 +198,13 @@ world. Custom domains and TLS are supported. pages_nginx['enable'] = false gitlab_pages['cert'] = "/etc/gitlab/ssl/example.io.crt" gitlab_pages['cert_key'] = "/etc/gitlab/ssl/example.io.key" - gitlab_pages['external_http'] = '1.1.1.2:80' - gitlab_pages['external_https'] = '1.1.1.2:443' + gitlab_pages['external_http'] = ['1.1.1.2:80', '[2001::2]:80'] + gitlab_pages['external_https'] = ['1.1.1.2:443', '[2001::2]:443'] ``` where `1.1.1.1` is the primary IP address that GitLab is listening to and - `1.1.1.2` the secondary IP where the GitLab Pages daemon listens to. + `1.1.1.2` and `2001::2` are the secondary IPs where the GitLab Pages daemon + listens on. If you don't have IPv6, you can omit the IPv6 address. 1. [Reconfigure GitLab][reconfigure] diff --git a/features/steps/project/pages.rb b/features/steps/project/pages.rb index c80c6273807..4045955a8b9 100644 --- a/features/steps/project/pages.rb +++ b/features/steps/project/pages.rb @@ -53,13 +53,13 @@ class Spinach::Features::ProjectPages < Spinach::FeatureSteps end step 'pages are exposed on external HTTP address' do - allow(Gitlab.config.pages).to receive(:external_http).and_return('1.1.1.1:80') + allow(Gitlab.config.pages).to receive(:external_http).and_return(['1.1.1.1:80']) allow(Gitlab.config.pages).to receive(:external_https).and_return(nil) end step 'pages are exposed on external HTTPS address' do - allow(Gitlab.config.pages).to receive(:external_http).and_return('1.1.1.1:80') - allow(Gitlab.config.pages).to receive(:external_https).and_return('1.1.1.1:443') + allow(Gitlab.config.pages).to receive(:external_http).and_return(['1.1.1.1:80']) + allow(Gitlab.config.pages).to receive(:external_https).and_return(['1.1.1.1:443']) end step 'I should be able to add a New Domain' do diff --git a/lib/support/init.d/gitlab.default.example b/lib/support/init.d/gitlab.default.example index e5797d8fe3c..f6642527639 100644 --- a/lib/support/init.d/gitlab.default.example +++ b/lib/support/init.d/gitlab.default.example @@ -56,14 +56,14 @@ gitlab_workhorse_log="$app_root/log/gitlab-workhorse.log" # The value of -listen-http must be set to `gitlab.yml > pages > external_http` # as well. For example: # -# -listen-http 1.1.1.1:80 +# -listen-http 1.1.1.1:80 -listen-http [2001::1]:80 # # To enable HTTPS support for custom domains add the `-listen-https`, # `-root-cert` and `-root-key` directives in `gitlab_pages_options` below. # The value of -listen-https must be set to `gitlab.yml > pages > external_https` # as well. For example: # -# -listen-https 1.1.1.1:443 -root-cert /path/to/example.com.crt -root-key /path/to/example.com.key +# -listen-https 1.1.1.1:443 -listen-http [2001::1]:443 -root-cert /path/to/example.com.crt -root-key /path/to/example.com.key # # The -pages-domain must be specified the same as in `gitlab.yml > pages > host`. # Set `gitlab_pages_enabled=true` if you want to enable the Pages feature. -- cgit v1.2.1 From 534f02b9340d30d9bb751b429ffdfc6351abc1a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Sad=C5=82ocha?= Date: Thu, 9 Mar 2017 11:40:08 +0000 Subject: Fix miswording --- doc/ci/ssh_keys/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/ci/ssh_keys/README.md b/doc/ci/ssh_keys/README.md index 49e7ac38b26..d00faaadc8b 100644 --- a/doc/ci/ssh_keys/README.md +++ b/doc/ci/ssh_keys/README.md @@ -30,8 +30,8 @@ This is the universal solution which works with any type of executor ## SSH keys when using the Docker executor You will first need to create an SSH key pair. For more information, follow the -instructions to [generate an SSH key](../../ssh/README.md). Do not add a comment -to the SSH key, or the `before_script` will prompt for a passphrase. +instructions to [generate an SSH key](../../ssh/README.md). Do not add a +passphrase to the SSH key, or the `before_script` will prompt for it. Then, create a new **Secret Variable** in your project settings on GitLab following **Settings > Variables**. As **Key** add the name `SSH_PRIVATE_KEY` -- cgit v1.2.1 From 7eabb7a9641481d89ccb52b421dcbd8cd63c3bb6 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Thu, 9 Mar 2017 12:32:43 +0000 Subject: Use reduce instead of a forEach Changed an isArray check to use -1 Added comment to boards search manager to explain behaviour --- app/assets/javascripts/boards/boards_bundle.js | 15 +++++++++++++-- app/assets/javascripts/boards/components/board_card.js | 3 ++- .../javascripts/boards/components/issue_card_inner.js | 10 +++++++++- app/assets/javascripts/boards/filtered_search_boards.js | 3 +++ app/assets/javascripts/boards/models/list.js | 14 +++++++------- 5 files changed, 34 insertions(+), 11 deletions(-) diff --git a/app/assets/javascripts/boards/boards_bundle.js b/app/assets/javascripts/boards/boards_bundle.js index 2fd1f43f02c..4d60fedaeb8 100644 --- a/app/assets/javascripts/boards/boards_bundle.js +++ b/app/assets/javascripts/boards/boards_bundle.js @@ -62,7 +62,13 @@ $(() => { created () { gl.boardService = new BoardService(this.endpoint, this.bulkUpdatePath, this.boardId); - gl.boardsFilterManager = new FilteredSearchBoards(Store.filter, true); + this.filterManager = new FilteredSearchBoards(Store.filter, true); + + // Listen for updateTokens event + this.$on('updateTokens', this.updateTokens); + }, + beforeDestroy() { + this.$off('updateTokens', this.updateTokens); }, mounted () { Store.disabled = this.disabled; @@ -81,7 +87,12 @@ $(() => { Store.addBlankState(); this.loading = false; }); - } + }, + methods: { + updateTokens() { + this.filterManager.updateTokens(); + } + }, }); gl.IssueBoardsSearch = new Vue({ diff --git a/app/assets/javascripts/boards/components/board_card.js b/app/assets/javascripts/boards/components/board_card.js index 795b3cf2ec0..4b72090df31 100644 --- a/app/assets/javascripts/boards/components/board_card.js +++ b/app/assets/javascripts/boards/components/board_card.js @@ -17,7 +17,8 @@ export default { :list="list" :issue="issue" :issue-link-base="issueLinkBase" - :root-path="rootPath" /> + :root-path="rootPath" + :update-filters="true" /> `, components: { diff --git a/app/assets/javascripts/boards/components/issue_card_inner.js b/app/assets/javascripts/boards/components/issue_card_inner.js index dce573ed6ca..3d57ec429c6 100644 --- a/app/assets/javascripts/boards/components/issue_card_inner.js +++ b/app/assets/javascripts/boards/components/issue_card_inner.js @@ -23,6 +23,11 @@ type: String, required: true, }, + updateFilters: { + type: Boolean, + required: false, + default: false, + }, }, methods: { showLabel(label) { @@ -31,6 +36,8 @@ return !this.list.label || label.id !== this.list.label.id; }, filterByLabel(label, e) { + if (!this.updateFilters) return; + const filterPath = gl.issueBoards.BoardsStore.filter.path.split('&'); const labelTitle = encodeURIComponent(label.title); const param = `label_name[]=${labelTitle}`; @@ -46,7 +53,8 @@ gl.issueBoards.BoardsStore.filter.path = filterPath.join('&'); Store.updateFiltersUrl(); - gl.boardsFilterManager.updateTokens(); + + gl.IssueBoardsApp.$emit('updateTokens'); }, labelStyle(label) { return { diff --git a/app/assets/javascripts/boards/filtered_search_boards.js b/app/assets/javascripts/boards/filtered_search_boards.js index 3014557c440..47448b02bdd 100644 --- a/app/assets/javascripts/boards/filtered_search_boards.js +++ b/app/assets/javascripts/boards/filtered_search_boards.js @@ -4,6 +4,9 @@ export default class FilteredSearchBoards extends gl.FilteredSearchManager { this.store = store; this.updateUrl = updateUrl; + + // Issue boards is slightly different, we handle all the requests async + // instead or reloading the page, we just re-fire the list ajax requests this.isHandledAsync = true; } diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js index ad968d2120f..3251ca76b26 100644 --- a/app/assets/javascripts/boards/models/list.js +++ b/app/assets/javascripts/boards/models/list.js @@ -64,16 +64,14 @@ class List { } getIssues (emptyIssues = true) { - const data = { page: this.page }; - gl.issueBoards.BoardsStore.filter.path.split('&').forEach((filterParam) => { - if (filterParam === '') return; + const data = gl.issueBoards.BoardsStore.filter.path.split('&').reduce((data, filterParam) => { + if (filterParam === '') return data; const paramSplit = filterParam.split('='); const paramKeyNormalized = paramSplit[0].replace('[]', ''); const isArray = paramSplit[0].indexOf('[]'); - let value = decodeURIComponent(paramSplit[1]); - value = value.replace(/\+/g, ' '); + const value = decodeURIComponent(paramSplit[1]).replace(/\+/g, ' '); - if (isArray >= 0) { + if (isArray !== -1) { if (!data[paramKeyNormalized]) { data[paramKeyNormalized] = []; } @@ -82,7 +80,9 @@ class List { } else { data[paramKeyNormalized] = value; } - }); + + return data; + }, { page: this.page }); if (this.label && data.label_name) { data.label_name = data.label_name.filter(label => label !== this.label.title); -- cgit v1.2.1 From 50a8f16e165d45bf6b518c0e250e1f5a360a6d3f Mon Sep 17 00:00:00 2001 From: Alex Braha Stoll Date: Tue, 13 Dec 2016 02:29:12 -0200 Subject: Add count badges to both dashboard and group milestones --- app/controllers/dashboard/milestones_controller.rb | 1 + app/controllers/groups/milestones_controller.rb | 1 + app/models/global_milestone.rb | 22 ++++++++ app/views/dashboard/milestones/index.html.haml | 10 ++-- app/views/groups/milestones/index.html.haml | 2 +- app/views/projects/milestones/index.html.haml | 10 ++-- app/views/shared/_milestones_filter.html.haml | 12 ++--- .../24421-personal-milestone-count-badges.yml | 4 ++ spec/models/global_milestone_spec.rb | 63 ++++++++++++++++++++++ 9 files changed, 105 insertions(+), 20 deletions(-) create mode 100644 changelogs/unreleased/24421-personal-milestone-count-badges.yml diff --git a/app/controllers/dashboard/milestones_controller.rb b/app/controllers/dashboard/milestones_controller.rb index 7f506db583f..df528d10f6e 100644 --- a/app/controllers/dashboard/milestones_controller.rb +++ b/app/controllers/dashboard/milestones_controller.rb @@ -5,6 +5,7 @@ class Dashboard::MilestonesController < Dashboard::ApplicationController def index respond_to do |format| format.html do + @milestone_states = GlobalMilestone.states_count(@projects) @milestones = Kaminari.paginate_array(milestones).page(params[:page]) end format.json do diff --git a/app/controllers/groups/milestones_controller.rb b/app/controllers/groups/milestones_controller.rb index 0d872c86c8a..43102596201 100644 --- a/app/controllers/groups/milestones_controller.rb +++ b/app/controllers/groups/milestones_controller.rb @@ -6,6 +6,7 @@ class Groups::MilestonesController < Groups::ApplicationController def index respond_to do |format| format.html do + @milestone_states = GlobalMilestone.states_count(@projects) @milestones = Kaminari.paginate_array(milestones).page(params[:page]) end end diff --git a/app/models/global_milestone.rb b/app/models/global_milestone.rb index b991d78e27f..0afbca2cb32 100644 --- a/app/models/global_milestone.rb +++ b/app/models/global_milestone.rb @@ -28,6 +28,28 @@ class GlobalMilestone new(title, child_milestones) end + def self.states_count(projects) + relation = MilestonesFinder.new.execute(projects, state: 'all') + milestones_by_state_and_title = relation.reorder(nil).group(:state, :title).count + + opened = count_by_state(milestones_by_state_and_title, 'active') + closed = count_by_state(milestones_by_state_and_title, 'closed') + all = milestones_by_state_and_title.map { |(_, title), _| title }.uniq.count + + { + opened: opened, + closed: closed, + all: all + } + end + + def self.count_by_state(milestones_by_state_and_title, state) + milestones_by_state_and_title.count do |(milestone_state, _), _| + milestone_state == state + end + end + private_class_method :count_by_state + def initialize(title, milestones) @title = title @name = title diff --git a/app/views/dashboard/milestones/index.html.haml b/app/views/dashboard/milestones/index.html.haml index 917bfbd47e9..505b475f55b 100644 --- a/app/views/dashboard/milestones/index.html.haml +++ b/app/views/dashboard/milestones/index.html.haml @@ -1,11 +1,11 @@ -- page_title "Milestones" -- header_title "Milestones", dashboard_milestones_path +- page_title 'Milestones' +- header_title 'Milestones', dashboard_milestones_path .top-area - = render 'shared/milestones_filter' + = render 'shared/milestones_filter', counts: @milestone_states .nav-controls - = render 'shared/new_project_item_select', path: 'milestones/new', label: "New Milestone", include_groups: true + = render 'shared/new_project_item_select', path: 'milestones/new', label: 'New Milestone', include_groups: true .milestones %ul.content-list @@ -15,4 +15,4 @@ - else - @milestones.each do |milestone| = render 'milestone', milestone: milestone - = paginate @milestones, theme: "gitlab" + = paginate @milestones, theme: 'gitlab' diff --git a/app/views/groups/milestones/index.html.haml b/app/views/groups/milestones/index.html.haml index 644895c56a1..6893168f039 100644 --- a/app/views/groups/milestones/index.html.haml +++ b/app/views/groups/milestones/index.html.haml @@ -2,7 +2,7 @@ = render "groups/head_issues" .top-area - = render 'shared/milestones_filter' + = render 'shared/milestones_filter', counts: @milestone_states .nav-controls - if can?(current_user, :admin_milestones, @group) diff --git a/app/views/projects/milestones/index.html.haml b/app/views/projects/milestones/index.html.haml index ad2bfbec915..918f5d161bb 100644 --- a/app/views/projects/milestones/index.html.haml +++ b/app/views/projects/milestones/index.html.haml @@ -1,14 +1,14 @@ - @no_container = true -- page_title "Milestones" -= render "projects/issues/head" +- page_title 'Milestones' += render 'projects/issues/head' %div{ class: container_class } .top-area - = render 'shared/milestones_filter' + = render 'shared/milestones_filter', counts: milestone_counts(@project.milestones) .nav-controls - if can?(current_user, :admin_milestone, @project) - = link_to new_namespace_project_milestone_path(@project.namespace, @project), class: "btn btn-new", title: "New Milestone" do + = link_to new_namespace_project_milestone_path(@project.namespace, @project), class: 'btn btn-new', title: 'New Milestone' do New Milestone .milestones @@ -19,4 +19,4 @@ %li .nothing-here-block No milestones to show - = paginate @milestones, theme: "gitlab" + = paginate @milestones, theme: 'gitlab' diff --git a/app/views/shared/_milestones_filter.html.haml b/app/views/shared/_milestones_filter.html.haml index 704893b4d5b..57a0eaa919e 100644 --- a/app/views/shared/_milestones_filter.html.haml +++ b/app/views/shared/_milestones_filter.html.haml @@ -1,19 +1,13 @@ -- if @project - - counts = milestone_counts(@project.milestones) - %ul.nav-links %li{ class: milestone_class_for_state(params[:state], 'opened', true) }> = link_to milestones_filter_path(state: 'opened') do Open - - if @project - %span.badge= counts[:opened] + %span.badge= counts[:opened] %li{ class: milestone_class_for_state(params[:state], 'closed') }> = link_to milestones_filter_path(state: 'closed') do Closed - - if @project - %span.badge= counts[:closed] + %span.badge= counts[:closed] %li{ class: milestone_class_for_state(params[:state], 'all') }> = link_to milestones_filter_path(state: 'all') do All - - if @project - %span.badge= counts[:all] + %span.badge= counts[:all] diff --git a/changelogs/unreleased/24421-personal-milestone-count-badges.yml b/changelogs/unreleased/24421-personal-milestone-count-badges.yml new file mode 100644 index 00000000000..8bbc1ed2dde --- /dev/null +++ b/changelogs/unreleased/24421-personal-milestone-count-badges.yml @@ -0,0 +1,4 @@ +--- +title: Add dashboard and group milestones count badges +merge_request: 9836 +author: Alex Braha Stoll diff --git a/spec/models/global_milestone_spec.rb b/spec/models/global_milestone_spec.rb index cacbab8bcb1..55b87d1c48a 100644 --- a/spec/models/global_milestone_spec.rb +++ b/spec/models/global_milestone_spec.rb @@ -92,6 +92,41 @@ describe GlobalMilestone, models: true do end end + describe '.states_count' do + context 'when the projects have milestones' do + before do + create(:closed_milestone, title: 'Active Group Milestone', project: project3) + create(:active_milestone, title: 'Active Group Milestone', project: project1) + create(:active_milestone, title: 'Active Group Milestone', project: project2) + create(:closed_milestone, title: 'Closed Group Milestone', project: project1) + create(:closed_milestone, title: 'Closed Group Milestone', project: project2) + create(:closed_milestone, title: 'Closed Group Milestone', project: project3) + end + + it 'returns the quantity of global milestones in each possible state' do + expected_count = { opened: 1, closed: 2, all: 2 } + + count = GlobalMilestone.states_count(Project.all) + + expect(count).to eq(expected_count) + end + end + + context 'when the projects do not have milestones' do + before do + project1 + end + + it 'returns 0 as the quantity of global milestones in each state' do + expected_count = { opened: 0, closed: 0, all: 0 } + + count = GlobalMilestone.states_count(Project.all) + + expect(count).to eq(expected_count) + end + end + end + describe '#initialize' do let(:milestone1_project1) { create(:milestone, title: "Milestone v1.2", project: project1) } let(:milestone1_project2) { create(:milestone, title: "Milestone v1.2", project: project2) } @@ -127,4 +162,32 @@ describe GlobalMilestone, models: true do expect(global_milestone.safe_title).to eq('git-test') end end + + describe '#state' do + context 'when at least one milestone is active' do + it 'returns active' do + title = 'Active Group Milestone' + milestones = [ + create(:active_milestone, title: title), + create(:closed_milestone, title: title) + ] + global_milestone = GlobalMilestone.new(title, milestones) + + expect(global_milestone.state).to eq('active') + end + end + + context 'when all milestones are closed' do + it 'returns closed' do + title = 'Closed Group Milestone' + milestones = [ + create(:closed_milestone, title: title), + create(:closed_milestone, title: title) + ] + global_milestone = GlobalMilestone.new(title, milestones) + + expect(global_milestone.state).to eq('closed') + end + end + end end -- cgit v1.2.1 From e8d525d07c30329d7cbe7b418296d0d9b9a391c0 Mon Sep 17 00:00:00 2001 From: Filipa Lacerda Date: Fri, 10 Mar 2017 19:48:09 +0000 Subject: Adds best practices regarding context and vue to documentation --- doc/development/frontend.md | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/doc/development/frontend.md b/doc/development/frontend.md index d646de7c54a..d540dce5729 100644 --- a/doc/development/frontend.md +++ b/doc/development/frontend.md @@ -66,6 +66,8 @@ Let's look into each of them: This is the index file of your new feature. This is where the root Vue instance of the new feature should be. +The Store and the Service should be loaded in this file and provided as a prop to the main component. + Don't forget to follow [these steps.][page_specific_javascript] **A folder for Components** @@ -86,7 +88,7 @@ You can read more about components in Vue.js site, [Component System][component- **A folder for the Store** -The Store is a simple object that allows us to manage the state in a single +The Store is a class that allows us to manage the state in a single source of truth. The concept we are trying to follow is better explained by Vue documentation @@ -332,6 +334,34 @@ gl.MyThing = MyThing; ``` +### Manipulating the DOM in a JS Class + +When writing a class that needs to manipulate the DOM guarantee a container option is provided. +This is useful when we need that class to be instantiated more than once in the same page. + +Bad: +``` +class Foo { + constructor() { + document.querySelector('.bar'); + } +} + +new Foo(); +``` + +Good: +``` +class Foo { + constructor(opts) { + document.querySelector(opts.container); + } +} + +new Foo({ container: '.my-element' }); +``` +You can find an example of the above in this [class][container-class-example]; + ## Supported browsers For our currently-supported browsers, see our [requirements][requirements]. @@ -457,3 +487,4 @@ Scenario: Developer can approve merge request [eslintrc]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/.eslintrc [team-page]: https://about.gitlab.com/team [vue-section]: https://docs.gitlab.com/ce/development/frontend.html#how-to-build-a-new-feature-with-vue-js +[container-class-example]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/app/assets/javascripts/mini_pipeline_graph_dropdown.js -- cgit v1.2.1 From 6e7e9e80e0a89c2c295ccaa4b8469b5ed33acd27 Mon Sep 17 00:00:00 2001 From: Simon Knox Date: Tue, 21 Feb 2017 16:32:08 +1100 Subject: prevent filtering Issues by multiple milestones, authors, or assignees --- .../javascripts/filtered_search/dropdown_hint.js | 14 +++++----- .../javascripts/filtered_search/dropdown_utils.js | 18 ++++++++----- app/views/shared/issuable/_search_bar.html.haml | 2 +- changelogs/unreleased/27174-filter-filters.yml | 4 +++ .../filtered_search/dropdown_utils_spec.js | 31 ++++++++++++++++++++-- 5 files changed, 53 insertions(+), 16 deletions(-) create mode 100644 changelogs/unreleased/27174-filter-filters.yml diff --git a/app/assets/javascripts/filtered_search/dropdown_hint.js b/app/assets/javascripts/filtered_search/dropdown_hint.js index 38ff3fb7158..28e5e3232cb 100644 --- a/app/assets/javascripts/filtered_search/dropdown_hint.js +++ b/app/assets/javascripts/filtered_search/dropdown_hint.js @@ -57,13 +57,15 @@ require('./filtered_search_dropdown'); const dropdownData = []; [].forEach.call(this.input.closest('.filtered-search-input-container').querySelectorAll('.dropdown-menu'), (dropdownMenu) => { - const { icon, hint, tag } = dropdownMenu.dataset; + const { icon, hint, tag, type } = dropdownMenu.dataset; if (icon && hint && tag) { - dropdownData.push({ - icon: `fa-${icon}`, - hint, - tag: `<${tag}>`, - }); + dropdownData.push( + Object.assign({ + icon: `fa-${icon}`, + hint, + tag: `<${tag}>`, + }, type && { type }), + ); } }); diff --git a/app/assets/javascripts/filtered_search/dropdown_utils.js b/app/assets/javascripts/filtered_search/dropdown_utils.js index a5a6b56a0d3..77bf191f343 100644 --- a/app/assets/javascripts/filtered_search/dropdown_utils.js +++ b/app/assets/javascripts/filtered_search/dropdown_utils.js @@ -51,14 +51,18 @@ static filterHint(input, item) { const updatedItem = item; - const searchInput = gl.DropdownUtils.getSearchInput(input); - let { lastToken } = gl.FilteredSearchTokenizer.processTokens(searchInput); - lastToken = lastToken.key || lastToken || ''; - - if (!lastToken || searchInput.split('').last() === ' ') { + const searchInput = gl.DropdownUtils.getSearchQuery(input); + const { lastToken, tokens } = gl.FilteredSearchTokenizer.processTokens(searchInput); + const lastKey = lastToken.key || lastToken || ''; + const allowMultiple = item.type === 'array'; + const itemInExistingTokens = tokens.some(t => t.key === item.hint); + + if (!allowMultiple && itemInExistingTokens) { + updatedItem.droplab_hidden = true; + } else if (!lastKey || searchInput.split('').last() === ' ') { updatedItem.droplab_hidden = false; - } else if (lastToken) { - const split = lastToken.split(':'); + } else if (lastKey) { + const split = lastKey.split(':'); const tokenName = split[0].split(' ').last(); const match = updatedItem.hint.indexOf(tokenName.toLowerCase()) === -1; diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index f8123846596..46e8c259a84 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -73,7 +73,7 @@ %li.filter-dropdown-item %button.btn.btn-link.js-data-value {{title}} - #js-dropdown-label.dropdown-menu{ data: { icon: 'tag', hint: 'label', tag: '~label' } } + #js-dropdown-label.dropdown-menu{ data: { icon: 'tag', hint: 'label', tag: '~label', type: 'array' } } %ul{ data: { dropdown: true } } %li.filter-dropdown-item{ data: { value: 'none' } } %button.btn.btn-link diff --git a/changelogs/unreleased/27174-filter-filters.yml b/changelogs/unreleased/27174-filter-filters.yml new file mode 100644 index 00000000000..0da1e4d5d3b --- /dev/null +++ b/changelogs/unreleased/27174-filter-filters.yml @@ -0,0 +1,4 @@ +--- +title: Prevent filtering issues by multiple Milestones or Authors +merge_request: +author: diff --git a/spec/javascripts/filtered_search/dropdown_utils_spec.js b/spec/javascripts/filtered_search/dropdown_utils_spec.js index 5c65903701b..e6538020896 100644 --- a/spec/javascripts/filtered_search/dropdown_utils_spec.js +++ b/spec/javascripts/filtered_search/dropdown_utils_spec.js @@ -126,7 +126,11 @@ require('~/filtered_search/filtered_search_dropdown_manager'); beforeEach(() => { setFixtures(` - +
    +
  • + +
  • +
`); input = document.getElementById('test'); @@ -142,7 +146,7 @@ require('~/filtered_search/filtered_search_dropdown_manager'); input.value = 'o'; updatedItem = gl.DropdownUtils.filterHint(input, { hint: 'label', - }, 'o'); + }); expect(updatedItem.droplab_hidden).toBe(true); }); @@ -150,6 +154,29 @@ require('~/filtered_search/filtered_search_dropdown_manager'); const updatedItem = gl.DropdownUtils.filterHint(input, {}, ''); expect(updatedItem.droplab_hidden).toBe(false); }); + + it('should allow multiple if item.type is array', () => { + input.value = 'label:~first la'; + const updatedItem = gl.DropdownUtils.filterHint(input, { + hint: 'label', + type: 'array', + }); + expect(updatedItem.droplab_hidden).toBe(false); + }); + + it('should prevent multiple if item.type is not array', () => { + input.value = 'milestone:~first mile'; + let updatedItem = gl.DropdownUtils.filterHint(input, { + hint: 'milestone', + }); + expect(updatedItem.droplab_hidden).toBe(true); + + updatedItem = gl.DropdownUtils.filterHint(input, { + hint: 'milestone', + type: 'string', + }); + expect(updatedItem.droplab_hidden).toBe(true); + }); }); describe('setDataValueIfSelected', () => { -- cgit v1.2.1 From c50833571946cbabcebb3b0da9bba5b625981ab5 Mon Sep 17 00:00:00 2001 From: Filipa Lacerda Date: Fri, 10 Mar 2017 23:15:18 +0000 Subject: Fix code examples and add code highligth --- doc/development/frontend.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/doc/development/frontend.md b/doc/development/frontend.md index d540dce5729..c5ee44988ac 100644 --- a/doc/development/frontend.md +++ b/doc/development/frontend.md @@ -291,7 +291,7 @@ When exactly one object is needed for a given task, prefer to define it as a `class` rather than as an object literal. Prefer also to explicitly restrict instantiation, unless flexibility is important (e.g. for testing). -``` +```javascript // bad gl.MyThing = { @@ -340,21 +340,20 @@ When writing a class that needs to manipulate the DOM guarantee a container opti This is useful when we need that class to be instantiated more than once in the same page. Bad: -``` +```javascript class Foo { constructor() { document.querySelector('.bar'); } } - new Foo(); ``` Good: -``` +```javascript class Foo { constructor(opts) { - document.querySelector(opts.container); + opts.container.querySelector('.bar'); } } -- cgit v1.2.1 From 167e4a3072d42d5e741d9d52feb47ef71cb9b750 Mon Sep 17 00:00:00 2001 From: Simon Knox Date: Sat, 11 Mar 2017 10:37:21 +1100 Subject: don't show scrollbar on search field unless necessary --- app/assets/stylesheets/framework/filters.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss index 8f2150066c7..ebfa1abbf84 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -144,7 +144,7 @@ .scroll-container { display: -webkit-flex; display: flex; - overflow-x: scroll; + overflow-x: auto; white-space: nowrap; width: 100%; } -- cgit v1.2.1 From 245020a127b8e3b95d16d0683f79d48895c8944c Mon Sep 17 00:00:00 2001 From: blackst0ne Date: Sat, 11 Mar 2017 19:02:21 +1100 Subject: Fix visibility level on new project page --- app/views/projects/new.html.haml | 3 ++- changelogs/unreleased/fix_visibility_level.yml | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 changelogs/unreleased/fix_visibility_level.yml diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml index 2a98bba05ee..1a51f777bd9 100644 --- a/app/views/projects/new.html.haml +++ b/app/views/projects/new.html.haml @@ -1,5 +1,6 @@ - page_title 'New Project' - header_title "Projects", dashboard_projects_path +- visibility_level = params.try(:[], :project).try(:[], :visibility_level).to_i || default_project_visibility .project-edit-container .project-edit-errors @@ -95,7 +96,7 @@ = f.label :visibility_level, class: 'label-light' do Visibility Level = link_to icon('question-circle'), help_page_path("public_access/public_access") - = render 'shared/visibility_level', f: f, visibility_level: default_project_visibility, can_change_visibility_level: true, form_model: @project, with_label: false + = render 'shared/visibility_level', f: f, visibility_level: visibility_level, can_change_visibility_level: true, form_model: @project, with_label: false = f.submit 'Create project', class: "btn btn-create project-submit", tabindex: 4 = link_to 'Cancel', dashboard_projects_path, class: 'btn btn-cancel' diff --git a/changelogs/unreleased/fix_visibility_level.yml b/changelogs/unreleased/fix_visibility_level.yml new file mode 100644 index 00000000000..4cf649124ca --- /dev/null +++ b/changelogs/unreleased/fix_visibility_level.yml @@ -0,0 +1,4 @@ +--- +title: Fix visibility level on new project page +merge_request: 9885 +author: blackst0ne -- cgit v1.2.1 From 5c209d91c2748935c70b7906015a430d1b85b4b0 Mon Sep 17 00:00:00 2001 From: blackst0ne Date: Sat, 11 Mar 2017 20:09:39 +1100 Subject: Added specs --- spec/features/projects/new_project_spec.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/spec/features/projects/new_project_spec.rb b/spec/features/projects/new_project_spec.rb index 45185f2dd1f..651a46f62b1 100644 --- a/spec/features/projects/new_project_spec.rb +++ b/spec/features/projects/new_project_spec.rb @@ -53,6 +53,7 @@ feature "New project", feature: true do click_button('Create project') expect(page).to have_css '.project-edit-errors .alert.alert-danger' + expect(find("[name='project[visibility_level]'][checked].option-title").value).to eq('Internal') end it "selects the group namespace" do -- cgit v1.2.1 From 93e204ea524830baa47be8d6bbeb6f5aa39d7991 Mon Sep 17 00:00:00 2001 From: blackst0ne Date: Sat, 11 Mar 2017 21:47:48 +1100 Subject: Update specs --- spec/features/projects/new_project_spec.rb | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/spec/features/projects/new_project_spec.rb b/spec/features/projects/new_project_spec.rb index 651a46f62b1..1dc1179c795 100644 --- a/spec/features/projects/new_project_spec.rb +++ b/spec/features/projects/new_project_spec.rb @@ -53,7 +53,6 @@ feature "New project", feature: true do click_button('Create project') expect(page).to have_css '.project-edit-errors .alert.alert-danger' - expect(find("[name='project[visibility_level]'][checked].option-title").value).to eq('Internal') end it "selects the group namespace" do @@ -61,6 +60,12 @@ feature "New project", feature: true do expect(namespace.text).to eq group.name end + + it 'selects the visibility level' do + level = Gitlab::VisibilityLevel.options['Internal'] + + expect(find_field("project_visibility_level_#{level}")).to be_checked + end end end end -- cgit v1.2.1 From 6625d605ec8405b1d687b13e7624b10ced832fd2 Mon Sep 17 00:00:00 2001 From: blackst0ne Date: Sun, 12 Mar 2017 00:59:09 +1100 Subject: Refactor specs --- spec/features/projects/new_project_spec.rb | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/spec/features/projects/new_project_spec.rb b/spec/features/projects/new_project_spec.rb index 1dc1179c795..e153eb2bc42 100644 --- a/spec/features/projects/new_project_spec.rb +++ b/spec/features/projects/new_project_spec.rb @@ -16,6 +16,15 @@ feature "New project", feature: true do expect(find_field("project_visibility_level_#{level}")).to be_checked end + + it 'saves visibility level on validation error' do + visit new_project_path + + choose(key) + click_button('Create project') + + expect(find_field("project_visibility_level_#{level}")).to be_checked + end end end @@ -60,12 +69,6 @@ feature "New project", feature: true do expect(namespace.text).to eq group.name end - - it 'selects the visibility level' do - level = Gitlab::VisibilityLevel.options['Internal'] - - expect(find_field("project_visibility_level_#{level}")).to be_checked - end end end end -- cgit v1.2.1 From d274bbb9b93183cb74f7ba4c3de286ebd5b0cb5a Mon Sep 17 00:00:00 2001 From: blackst0ne Date: Sun, 12 Mar 2017 01:20:22 +1100 Subject: Fix specs --- app/views/projects/new.html.haml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml index 1a51f777bd9..560e3439fc2 100644 --- a/app/views/projects/new.html.haml +++ b/app/views/projects/new.html.haml @@ -1,6 +1,6 @@ - page_title 'New Project' - header_title "Projects", dashboard_projects_path -- visibility_level = params.try(:[], :project).try(:[], :visibility_level).to_i || default_project_visibility +- visibility_level = params.try(:[], :project).try(:[], :visibility_level) || default_project_visibility .project-edit-container .project-edit-errors @@ -96,7 +96,7 @@ = f.label :visibility_level, class: 'label-light' do Visibility Level = link_to icon('question-circle'), help_page_path("public_access/public_access") - = render 'shared/visibility_level', f: f, visibility_level: visibility_level, can_change_visibility_level: true, form_model: @project, with_label: false + = render 'shared/visibility_level', f: f, visibility_level: visibility_level.to_i, can_change_visibility_level: true, form_model: @project, with_label: false = f.submit 'Create project', class: "btn btn-create project-submit", tabindex: 4 = link_to 'Cancel', dashboard_projects_path, class: 'btn btn-cancel' -- cgit v1.2.1 From 42151a15df1b920f7092e3f7c6f31cb24136cff9 Mon Sep 17 00:00:00 2001 From: blackst0ne Date: Sun, 12 Mar 2017 08:01:43 +1100 Subject: Fix rubocop offenses --- spec/features/projects/new_project_spec.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/spec/features/projects/new_project_spec.rb b/spec/features/projects/new_project_spec.rb index e153eb2bc42..52196ce49bd 100644 --- a/spec/features/projects/new_project_spec.rb +++ b/spec/features/projects/new_project_spec.rb @@ -18,12 +18,12 @@ feature "New project", feature: true do end it 'saves visibility level on validation error' do - visit new_project_path + visit new_project_path - choose(key) - click_button('Create project') + choose(key) + click_button('Create project') - expect(find_field("project_visibility_level_#{level}")).to be_checked + expect(find_field("project_visibility_level_#{level}")).to be_checked end end end -- cgit v1.2.1 From 0267b83898d604181e70c5ea8ac4a84108d2e6d6 Mon Sep 17 00:00:00 2001 From: Bob Van Landuyt Date: Thu, 22 Dec 2016 13:31:12 +0100 Subject: Delegate a single discussion to a new issue Delegate a discussion in a merge request into a new issue. The discussion wil be marked as resolved and a system note will be added linking to the newly created issue. --- .../components/new_issue_for_discussion.js.es6 | 29 ++++++ .../javascripts/diff_notes/diff_notes_bundle.js | 3 +- app/assets/stylesheets/pages/note_form.scss | 21 +++- app/controllers/projects/issues_controller.rb | 24 ++++- app/finders/notes_finder.rb | 10 ++ app/services/discussions/resolve_service.rb | 8 +- app/services/issues/base_service.rb | 25 ++++- app/services/issues/build_service.rb | 21 ++-- app/services/issues/create_service.rb | 17 ++-- .../_new_issue_for_discussion.html.haml | 8 ++ app/views/discussions/_notes.html.haml | 4 +- app/views/shared/icons/_icon_mr_issue.svg | 1 + app/views/shared/issuable/_form.html.haml | 18 +++- ...515-delegate-single-discussion-to-new-issue.yml | 4 + doc/api/issues.md | 19 ++-- .../img/new_issue_for_discussion.png | Bin 0 -> 39563 bytes .../img/preview_issue_for_discussion.png | Bin 0 -> 176569 bytes .../merge_request_discussion_resolution.md | 19 ++++ lib/api/issues.rb | 7 ++ .../controllers/projects/issues_controller_spec.rb | 9 ++ spec/factories/notes.rb | 7 +- ..._issue_for_discussions_in_merge_request_spec.rb | 26 +---- ...issue_for_single_discussion_in_merge_request.rb | 63 ++++++++++++ spec/finders/notes_finder_spec.rb | 33 ++++++ spec/requests/api/issues_spec.rb | 30 +++--- spec/services/discussions/resolve_service_spec.rb | 2 +- spec/services/issues/base_service_spec.rb | 112 +++++++++++++++++++++ spec/services/issues/build_service_spec.rb | 39 ++++--- spec/services/issues/create_service_spec.rb | 85 +++++++++++----- ...issues_resolving_discussions_shared_examples.rb | 15 +++ ...olving_discussions_in_issues_shared_examples.rb | 25 +++++ 31 files changed, 571 insertions(+), 113 deletions(-) create mode 100644 app/assets/javascripts/diff_notes/components/new_issue_for_discussion.js.es6 create mode 100644 app/views/discussions/_new_issue_for_discussion.html.haml create mode 100644 app/views/shared/icons/_icon_mr_issue.svg create mode 100644 changelogs/unreleased/25515-delegate-single-discussion-to-new-issue.yml create mode 100644 doc/user/project/merge_requests/img/new_issue_for_discussion.png create mode 100644 doc/user/project/merge_requests/img/preview_issue_for_discussion.png create mode 100644 spec/features/issues/create_issue_for_single_discussion_in_merge_request.rb create mode 100644 spec/services/issues/base_service_spec.rb create mode 100644 spec/support/api/issues_resolving_discussions_shared_examples.rb create mode 100644 spec/support/features/resolving_discussions_in_issues_shared_examples.rb diff --git a/app/assets/javascripts/diff_notes/components/new_issue_for_discussion.js.es6 b/app/assets/javascripts/diff_notes/components/new_issue_for_discussion.js.es6 new file mode 100644 index 00000000000..e86bef47172 --- /dev/null +++ b/app/assets/javascripts/diff_notes/components/new_issue_for_discussion.js.es6 @@ -0,0 +1,29 @@ +/* global Vue */ +/* global CommentsStore */ + +(() => { + const NewIssueForDiscussion = Vue.extend({ + props: { + discussionId: { + type: String, + required: true, + }, + }, + data() { + return { + discussions: CommentsStore.state, + }; + }, + computed: { + discussion() { + return this.discussions[this.discussionId]; + }, + showButton() { + if (this.discussion) return !this.discussion.isResolved(); + return false; + }, + }, + }); + + Vue.component('new-issue-for-discussion-btn', NewIssueForDiscussion); +})(); diff --git a/app/assets/javascripts/diff_notes/diff_notes_bundle.js b/app/assets/javascripts/diff_notes/diff_notes_bundle.js index 7d8316dfd63..4f6b86a917c 100644 --- a/app/assets/javascripts/diff_notes/diff_notes_bundle.js +++ b/app/assets/javascripts/diff_notes/diff_notes_bundle.js @@ -14,10 +14,11 @@ require('./components/resolve_btn'); require('./components/resolve_count'); require('./components/resolve_discussion_btn'); require('./components/diff_note_avatars'); +require('./components/new_issue_for_discussion'); $(() => { const projectPath = document.querySelector('.merge-request').dataset.projectPath; - const COMPONENT_SELECTOR = 'resolve-btn, resolve-discussion-btn, jump-to-discussion, comment-and-resolve-btn'; + const COMPONENT_SELECTOR = 'resolve-btn, resolve-discussion-btn, jump-to-discussion, comment-and-resolve-btn, new-issue-for-discussion-btn'; window.gl = window.gl || {}; window.gl.diffNoteApps = {}; diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss index f984b469609..c2156a5ac69 100644 --- a/app/assets/stylesheets/pages/note_form.scss +++ b/app/assets/stylesheets/pages/note_form.scss @@ -178,8 +178,25 @@ padding-right: 5px; } - &:last-child { - padding-left: 5px; + } + + .discussion-actions { + display: table; + + .new-issue-for-discussion path { + fill: $gray-darkest; + } + + .btn-group { + display: table-cell; + + &:first-child { + padding-right: 0; + } + + &:first-child:not(:last-child) > div { + border-right: 0; + } } } diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 1151555b8fa..d7eb73d7ff5 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -64,7 +64,12 @@ class Projects::IssuesController < Projects::ApplicationController params[:issue] ||= ActionController::Parameters.new( assignee_id: "" ) - build_params = issue_params.merge(merge_request_for_resolving_discussions: merge_request_for_resolving_discussions) + + build_params = issue_params.merge( + merge_request_for_resolving_discussions: merge_request_for_resolving_discussions, + discussion_to_resolve: discussion_to_resolve + ) + @issue = @noteable = Issues::BuildService.new(project, current_user, build_params).execute respond_with(@issue) @@ -94,10 +99,12 @@ class Projects::IssuesController < Projects::ApplicationController end def create - create_params = issue_params - .merge(merge_request_for_resolving_discussions: merge_request_for_resolving_discussions) - .merge(spammable_params) - + create_params = issue_params. + merge( + merge_request_for_resolving_discussions: merge_request_for_resolving_discussions, + discussion_to_resolve: discussion_to_resolve + ). + merge(spammable_params) @issue = Issues::CreateService.new(project, current_user, create_params).execute respond_to do |format| @@ -193,6 +200,13 @@ class Projects::IssuesController < Projects::ApplicationController find_by(iid: merge_request_iid) end + def discussion_to_resolve + return unless discussion_id = params[:discussion_to_resolve] + + @discussion_to_resolve ||= NotesFinder.new(project, current_user, discussion_id: discussion_id). + first_discussion + end + def authorize_read_issue! return render_404 unless can?(current_user, :read_issue, @issue) end diff --git a/app/finders/notes_finder.rb b/app/finders/notes_finder.rb index 6630c6384f2..c93e6b05a67 100644 --- a/app/finders/notes_finder.rb +++ b/app/finders/notes_finder.rb @@ -11,6 +11,7 @@ class NotesFinder # target_type: string # target_id: integer # last_fetched_at: time + # discussion_id: string # search: string # def initialize(project, current_user, params = {}) @@ -22,9 +23,14 @@ class NotesFinder def execute @notes = since_fetch_at(@params[:last_fetched_at]) if @params[:last_fetched_at] + @notes = for_discussion(@params[:discussion_id]) if @params[:discussion_id] @notes end + def first_discussion + execute.discussions.first + end + private def init_collection @@ -100,4 +106,8 @@ class NotesFinder @notes.where('updated_at > ?', last_fetched_at - FETCH_OVERLAP).fresh end + + def for_discussion(discussion_id) + @notes.where(Note.arel_table[:discussion_id].eq(discussion_id)) + end end diff --git a/app/services/discussions/resolve_service.rb b/app/services/discussions/resolve_service.rb index 0437195f588..4a803b47bba 100644 --- a/app/services/discussions/resolve_service.rb +++ b/app/services/discussions/resolve_service.rb @@ -9,7 +9,13 @@ module Discussions discussion.resolve!(current_user) - MergeRequests::ResolvedDiscussionNotificationService.new(project, current_user).execute(merge_request) + notify_discussion_resolved(discussion) + end + + def notify_discussion_resolved(discussion) + noteable = merge_request || discussion.noteable + + MergeRequests::ResolvedDiscussionNotificationService.new(project, current_user).execute(noteable) SystemNoteService.discussion_continued_in_issue(discussion, project, current_user, follow_up_issue) if follow_up_issue end diff --git a/app/services/issues/base_service.rb b/app/services/issues/base_service.rb index 35af867a098..3aa842eb9a8 100644 --- a/app/services/issues/base_service.rb +++ b/app/services/issues/base_service.rb @@ -1,11 +1,12 @@ module Issues class BaseService < ::IssuableBaseService - attr_reader :merge_request_for_resolving_discussions + attr_reader :merge_request_for_resolving_discussions, :discussion_to_resolve def initialize(*args) super @merge_request_for_resolving_discussions ||= params.delete(:merge_request_for_resolving_discussions) + @discussion_to_resolve ||= params.delete(:discussion_to_resolve) end def hook_data(issue, action) @@ -15,6 +16,28 @@ module Issues issue_data end + def merge_request_for_resolving_discussions + @merge_request_for_resolving_discussions ||= discussion_to_resolve.try(:noteable) + end + + def for_all_discussions_in_a_merge_request? + discussion_to_resolve.nil? && merge_request_for_resolving_discussions + end + + def for_single_discussion? + discussion_to_resolve && discussion_to_resolve.noteable == merge_request_for_resolving_discussions + end + + def discussions_to_resolve + @discussions_to_resolve ||= if for_all_discussions_in_a_merge_request? + merge_request_for_resolving_discussions.resolvable_discussions + elsif for_single_discussion? + Array(discussion_to_resolve) + else + [] + end + end + private def execute_hooks(issue, action = 'open') diff --git a/app/services/issues/build_service.rb b/app/services/issues/build_service.rb index 7cd927d8005..1bcc5abd3f2 100644 --- a/app/services/issues/build_service.rb +++ b/app/services/issues/build_service.rb @@ -4,32 +4,35 @@ module Issues @issue = project.issues.new(issue_params) end - def issue_params_with_info_from_merge_request + def issue_params_with_info_from_discussions return {} unless merge_request_for_resolving_discussions - { title: title_from_merge_request, description: description_from_merge_request } + { title: title_for_merge_request, description: description_for_discussions } end - def title_from_merge_request + def title_for_merge_request "Follow-up from \"#{merge_request_for_resolving_discussions.title}\"" end - def description_from_merge_request - if merge_request_for_resolving_discussions.resolvable_discussions.empty? + def description_for_discussions + if discussions_to_resolve.empty? return "There are no unresolved discussions. "\ "Review the conversation in #{merge_request_for_resolving_discussions.to_reference}" end - description = "The following discussions from #{merge_request_for_resolving_discussions.to_reference} should be addressed:" + description = "The following #{'discussion'.pluralize(discussions_to_resolve.size)} "\ + "from #{merge_request_for_resolving_discussions.to_reference} "\ + "should be addressed:" + [description, *items_for_discussions].join("\n\n") end def items_for_discussions - merge_request_for_resolving_discussions.resolvable_discussions.map { |discussion| item_for_discussion(discussion) } + discussions_to_resolve.map { |discussion| item_for_discussion(discussion) } end def item_for_discussion(discussion) - first_note = discussion.first_note_to_resolve + first_note = discussion.first_note_to_resolve || discussion.first_note other_note_count = discussion.notes.size - 1 creation_time = first_note.created_at.to_s(:medium) note_url = Gitlab::UrlBuilder.build(first_note) @@ -44,7 +47,7 @@ module Issues end def issue_params - @issue_params ||= issue_params_with_info_from_merge_request.merge(whitelisted_issue_params) + @issue_params ||= issue_params_with_info_from_discussions.merge(whitelisted_issue_params) end def whitelisted_issue_params diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb index 85b6eb3fe3d..d2cc70ed0dc 100644 --- a/app/services/issues/create_service.rb +++ b/app/services/issues/create_service.rb @@ -5,7 +5,11 @@ module Issues def execute filter_spam_check_params - issue_attributes = params.merge(merge_request_for_resolving_discussions: merge_request_for_resolving_discussions) + issue_attributes = params.merge( + merge_request_for_resolving_discussions: merge_request_for_resolving_discussions, + discussion_to_resolve: discussion_to_resolve + ) + @issue = BuildService.new(project, current_user, issue_attributes).execute create(@issue) @@ -21,17 +25,16 @@ module Issues notification_service.new_issue(issuable, current_user) todo_service.new_issue(issuable, current_user) user_agent_detail_service.create - - if merge_request_for_resolving_discussions.try(:discussions_can_be_resolved_by?, current_user) - resolve_discussions_in_merge_request(issuable) - end + resolve_discussions_with_issue(issuable) end - def resolve_discussions_in_merge_request(issue) + def resolve_discussions_with_issue(issue) + return if discussions_to_resolve.empty? + Discussions::ResolveService.new(project, current_user, merge_request: merge_request_for_resolving_discussions, follow_up_issue: issue). - execute(merge_request_for_resolving_discussions.resolvable_discussions) + execute(discussions_to_resolve) end private diff --git a/app/views/discussions/_new_issue_for_discussion.html.haml b/app/views/discussions/_new_issue_for_discussion.html.haml new file mode 100644 index 00000000000..de28ded9820 --- /dev/null +++ b/app/views/discussions/_new_issue_for_discussion.html.haml @@ -0,0 +1,8 @@ +- if discussion.can_resolve?(current_user) && can?(current_user, :create_issue, @project) + %new-issue-for-discussion-btn{ ":discussion-id" => "'#{discussion.id}'", + "inline-template" => true } + .btn-group{ role: "group", "v-if" => "showButton" } + .btn.btn-default.discussion-create-issue-btn.has-tooltip{ title: "Resolve this discussion in a new issue", + "aria-label" => "Resolve this discussion in a new issue", + "data-container" => "body" } + = link_to custom_icon('icon_mr_issue'), new_namespace_project_issue_path(@project.namespace, @project, discussion_to_resolve: discussion.id), title: "Resolve this discussion in a new issue", class: 'new-issue-for-discussion' diff --git a/app/views/discussions/_notes.html.haml b/app/views/discussions/_notes.html.haml index dfdbdf1f969..4a1e93cec3a 100644 --- a/app/views/discussions/_notes.html.haml +++ b/app/views/discussions/_notes.html.haml @@ -11,6 +11,8 @@ = link_to_reply_discussion(discussion, line_type) = render "discussions/resolve_all", discussion: discussion - if discussion.for_merge_request? - = render "discussions/jump_to_next", discussion: discussion + .btn-group.discussion-actions + = render "discussions/new_issue_for_discussion", discussion: discussion + = render "discussions/jump_to_next", discussion: discussion - else = link_to_reply_discussion(discussion) diff --git a/app/views/shared/icons/_icon_mr_issue.svg b/app/views/shared/icons/_icon_mr_issue.svg new file mode 100644 index 00000000000..ae219a3ded2 --- /dev/null +++ b/app/views/shared/icons/_icon_mr_issue.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index 70470c83c51..e93fe0f6cb5 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -48,18 +48,32 @@ - if @merge_request_for_resolving_discussions .form-group .col-sm-10.col-sm-offset-2 + = icon('exclamation-triangle') - if @merge_request_for_resolving_discussions.discussions_can_be_resolved_by?(current_user) - = icon('exclamation-triangle') Creating this issue will mark all discussions in = link_to @merge_request_for_resolving_discussions.to_reference, merge_request_path(@merge_request_for_resolving_discussions) as resolved. = hidden_field_tag 'merge_request_for_resolving_discussions', @merge_request_for_resolving_discussions.iid - else - = icon('exclamation-triangle') You can't automatically mark all discussions in = link_to @merge_request_for_resolving_discussions.to_reference, merge_request_path(@merge_request_for_resolving_discussions) as resolved. Ask someone with sufficient rights to resolve the them. +- if @discussion_to_resolve + .form-group + .col-sm-10.col-sm-offset-2 + = icon('exclamation-triangle') + - if @discussion_to_resolve.can_resolve?(current_user) + Creating this issue will mark the discussion at + = link_to @discussion_to_resolve.noteable.to_reference, Gitlab::UrlBuilder.build(@discussion_to_resolve.first_note) + as resolved. + = hidden_field_tag 'discussion_to_resolve', @discussion_to_resolve.id + - else + You can't automatically mark the discussion at + = link_to @discussion_to_resolve.noteable.to_reference, Gitlab::UrlBuilder.build(@discussion_to_resolve.first_note) + as resolved. Ask someone with sufficient rights to resolve it. + + - is_footer = !(issuable.is_a?(MergeRequest) && issuable.new_record?) .row-content-block{ class: (is_footer ? "footer-block" : "middle-block") } .pull-right diff --git a/changelogs/unreleased/25515-delegate-single-discussion-to-new-issue.yml b/changelogs/unreleased/25515-delegate-single-discussion-to-new-issue.yml new file mode 100644 index 00000000000..5b755a8bc32 --- /dev/null +++ b/changelogs/unreleased/25515-delegate-single-discussion-to-new-issue.yml @@ -0,0 +1,4 @@ +--- +title: Create a new issue for a single discussion in a Merge Request +merge_request: 8266 +author: Bob Van Landuyt diff --git a/doc/api/issues.md b/doc/api/issues.md index e25841926f8..ddc304ea128 100644 --- a/doc/api/issues.md +++ b/doc/api/issues.md @@ -331,16 +331,17 @@ POST /projects/:id/issues | Attribute | Type | Required | Description | | --------- | ---- | -------- | ----------- | -| `id` | integer | yes | The ID of a project | -| `title` | string | yes | The title of an issue | -| `description` | string | no | The description of an issue | -| `confidential` | boolean | no | Set an issue to be confidential. Default is `false`. | -| `assignee_id` | integer | no | The ID of a user to assign issue | -| `milestone_id` | integer | no | The ID of a milestone to assign issue | -| `labels` | string | no | Comma-separated label names for an issue | -| `created_at` | string | no | Date time string, ISO 8601 formatted, e.g. `2016-03-11T03:45:40Z` (requires admin or project owner rights) | -| `due_date` | string | no | Date time string in the format YEAR-MONTH-DAY, e.g. `2016-03-11` | +| `id` | integer | yes | The ID of a project | +| `title` | string | yes | The title of an issue | +| `description` | string | no | The description of an issue | +| `confidential` | boolean | no | Set an issue to be confidential. Default is `false`. | +| `assignee_id` | integer | no | The ID of a user to assign issue | +| `milestone_id` | integer | no | The ID of a milestone to assign issue | +| `labels` | string | no | Comma-separated label names for an issue | +| `created_at` | string | no | Date time string, ISO 8601 formatted, e.g. `2016-03-11T03:45:40Z` (requires admin or project owner rights) | +| `due_date` | string | no | Date time string in the format YEAR-MONTH-DAY, e.g. `2016-03-11` | | `merge_request_for_resolving_discussions` | integer | no | The IID of a merge request in which to resolve all issues. This will fill the issue with a default description and mark all discussions as resolved. When passing a description or title, these values will take precedence over the default values. | +| `discussion_to_resolve` | string | no | The ID of a discussion to resolve. This will fill in the issue with a default description and mark the discussion as resolved. | ```bash curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/4/issues?title=Issues%20with%20auth&labels=bug diff --git a/doc/user/project/merge_requests/img/new_issue_for_discussion.png b/doc/user/project/merge_requests/img/new_issue_for_discussion.png new file mode 100644 index 00000000000..93c9dad8921 Binary files /dev/null and b/doc/user/project/merge_requests/img/new_issue_for_discussion.png differ diff --git a/doc/user/project/merge_requests/img/preview_issue_for_discussion.png b/doc/user/project/merge_requests/img/preview_issue_for_discussion.png new file mode 100644 index 00000000000..798365aa4db Binary files /dev/null and b/doc/user/project/merge_requests/img/preview_issue_for_discussion.png differ diff --git a/doc/user/project/merge_requests/merge_request_discussion_resolution.md b/doc/user/project/merge_requests/merge_request_discussion_resolution.md index d4b85676d19..63274c86bbb 100644 --- a/doc/user/project/merge_requests/merge_request_discussion_resolution.md +++ b/doc/user/project/merge_requests/merge_request_discussion_resolution.md @@ -72,9 +72,28 @@ add a note referring to the newly created issue. You can now proceed to merge the merge request from the UI. +## Moving a single discussion to a new issue + +> [Introduced][ce-8266] + +To create a new issue for a single discussion, you can use the **Resolve this +discussion in a new issue** button. + +![Create issue for discussion](img/new_issue_for_discussion.png) + +This will direct you to a new issue prefilled with the content of the +discussion, similar to the issues created for delegating multiple +discussions at once. + +![New issue for a single discussion](img/preview_issue_for_discussion.png) + +Saving the issue will mark the discussion as resolved and add a note +to the discussion referencing the new issue. + [ce-5022]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5022 [ce-7125]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/7125 [ce-7180]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/7180 +[ce-8266]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/8266 [resolve-discussion-button]: img/resolve_discussion_button.png [resolve-comment-button]: img/resolve_comment_button.png [discussion-view]: img/discussion_view.png diff --git a/lib/api/issues.rb b/lib/api/issues.rb index 4a9f2b26fb2..9aac3c8fe1a 100644 --- a/lib/api/issues.rb +++ b/lib/api/issues.rb @@ -118,6 +118,8 @@ module API desc: 'Date time when the issue was created. Available only for admins and project owners.' optional :merge_request_for_resolving_discussions, type: Integer, desc: 'The IID of a merge request for which to resolve discussions' + optional :discussion_to_resolve, type: String, + desc: 'The ID of a discussion to resolve' use :issue_params end post ':id/issues' do @@ -134,6 +136,11 @@ module API find_by(iid: merge_request_iid) end + if discussion_id = params[:discussion_to_resolve] + issue_params[:discussion_to_resolve] = NotesFinder.new(user_project, current_user, discussion_id: discussion_id). + first_discussion + end + issue = ::Issues::CreateService.new(user_project, current_user, issue_params.merge(request: request, api: true)).execute diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb index 46c758b4654..336e3b50a72 100644 --- a/spec/controllers/projects/issues_controller_spec.rb +++ b/spec/controllers/projects/issues_controller_spec.rb @@ -109,6 +109,15 @@ describe Projects::IssuesController do expect(assigns(:issue).title).not_to be_empty expect(assigns(:issue).description).not_to be_empty end + + it 'fills in an issue for a discussion' do + note = create(:note_on_merge_request, project: project) + + get :new, namespace_id: project.namespace.path, project_id: project, discussion_to_resolve: note.discussion_id + + expect(assigns(:issue).title).not_to be_empty + expect(assigns(:issue).description).not_to be_empty + end end context 'external issue tracker' do diff --git a/spec/factories/notes.rb b/spec/factories/notes.rb index 5c50cd7f4ad..fe19a404e16 100644 --- a/spec/factories/notes.rb +++ b/spec/factories/notes.rb @@ -26,12 +26,17 @@ FactoryGirl.define do factory :diff_note_on_merge_request, traits: [:on_merge_request], class: DiffNote do association :project, :repository + + transient do + line_number 14 + end + position do Gitlab::Diff::Position.new( old_path: "files/ruby/popen.rb", new_path: "files/ruby/popen.rb", old_line: nil, - new_line: 14, + new_line: line_number, diff_refs: noteable.diff_refs ) end diff --git a/spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb b/spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb index 762cab0c0e1..62ecd0100ae 100644 --- a/spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb +++ b/spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb @@ -42,35 +42,13 @@ feature 'Resolving all open discussions in a merge request from an issue', featu page.click_link 'open an issue to resolve them later', href: new_namespace_project_issue_path(project.namespace, project, merge_request_for_resolving_discussions: merge_request.iid) end - it 'shows an issue with the title filled in' do - title_field = page.find_field('issue[title]') - - expect(title_field.value).to include(merge_request.title) - end - - it 'has a mention of the discussion in the description' do - description_field = page.find_field('issue[description]') - - expect(description_field.value).to include(discussion.first_note.note) - end - - it 'has a hidden field for the merge request' do + it 'has a hidden field for the discussion' do merge_request_field = find('#merge_request_for_resolving_discussions', visible: false) expect(merge_request_field.value).to eq(merge_request.iid.to_s) end - it 'can create a new issue for the project' do - expect { click_button 'Submit issue' }.to change { project.issues.reload.size }.by(1) - end - - it 'resolves the discussion in the merge request' do - click_button 'Submit issue' - - discussion.first_note.reload - - expect(discussion.resolved?).to eq(true) - end + it_behaves_like 'creating an issue for a discussion' end end end diff --git a/spec/features/issues/create_issue_for_single_discussion_in_merge_request.rb b/spec/features/issues/create_issue_for_single_discussion_in_merge_request.rb new file mode 100644 index 00000000000..6cf67639b7e --- /dev/null +++ b/spec/features/issues/create_issue_for_single_discussion_in_merge_request.rb @@ -0,0 +1,63 @@ +require 'rails_helper' + +feature 'Resolve an open discussion in a merge request by creating an issue', feature: true do + let(:user) { create(:user) } + let(:project) { create(:project, only_allow_merge_if_all_discussions_are_resolved: true) } + let(:merge_request) { create(:merge_request, source_project: project) } + let!(:discussion) { Discussion.for_diff_notes([create(:diff_note_on_merge_request, noteable: merge_request, project: project)]).first } + + before do + project.team << [user, :master] + login_as user + visit namespace_project_merge_request_path(project.namespace, project, merge_request) + end + + context 'with the internal tracker disabled' do + before do + project.project_feature.update_attribute(:issues_access_level, ProjectFeature::DISABLED) + visit namespace_project_merge_request_path(project.namespace, project, merge_request) + end + + it 'does not show a link to create a new issue' do + expect(page).not_to have_link 'Resolve this discussion in a new issue' + end + end + + context 'resolving the discussion', js: true do + before do + click_button 'Resolve discussion' + end + + it 'hides the link for creating a new issue' do + expect(page).not_to have_link 'Resolve this discussion in a new issue' + end + + it 'shows the link for creating a new issue when unresolving a discussion' do + page.within '.diff-content' do + click_button 'Unresolve discussion' + end + + expect(page).to have_link 'Resolve this discussion in a new issue' + end + end + + it 'has a link to create a new issue for a discussion' do + new_issue_link = new_namespace_project_issue_path(project.namespace, project, discussion_to_resolve: discussion.id) + + expect(page).to have_link 'Resolve this discussion in a new issue', href: new_issue_link + end + + context 'creating the issue' do + before do + click_link 'Resolve this discussion in a new issue', href: new_namespace_project_issue_path(project.namespace, project, discussion_to_resolve: discussion.id) + end + + it 'has a hidden field for the discussion' do + discussion_field = find('#discussion_to_resolve', visible: false) + + expect(discussion_field.value).to eq(discussion.id.to_s) + end + + it_behaves_like 'creating an issue for a discussion' + end +end diff --git a/spec/finders/notes_finder_spec.rb b/spec/finders/notes_finder_spec.rb index 77a04507be1..5fc2e3dd527 100644 --- a/spec/finders/notes_finder_spec.rb +++ b/spec/finders/notes_finder_spec.rb @@ -41,6 +41,17 @@ describe NotesFinder do expect(notes.count).to eq(0) end + context 'for a certain discussion' do + let!(:note_in_a_commit_discussion) { create(:note_on_commit, project: project) } + let!(:other_note) { create(:note_on_merge_request, project: project) } + + it 'finds the only the notes for a certain discussion id' do + notes = described_class.new(project, user, discussion_id: note_in_a_commit_discussion.discussion_id).execute + + expect(notes).to contain_exactly(note_in_a_commit_discussion) + end + end + context 'on restricted projects' do let(:project) do create(:empty_project, @@ -146,6 +157,28 @@ describe NotesFinder do end end + describe "#first_discussion" do + let!(:discussion_note) { create(:note_on_merge_request, project: project) } + + it 'returns a discussion' do + discussion = described_class.new(project, user, discussion_id: discussion_note.discussion_id).first_discussion + + expect(discussion).is_a? Discussion + end + + it 'includes the notes in the discussion' do + discussion = described_class.new(project, user, discussion_id: discussion_note.discussion_id).first_discussion + + expect(discussion.notes).to include(discussion_note) + end + + it 'returns nil when no notes are found' do + discussion = described_class.new(project, user, discussion_id: 'non-existant').first_discussion + + expect(discussion).to be(nil) + end + end + describe '.search' do let(:project) { create(:empty_project, :public) } let(:note) { create(:note_on_issue, note: 'WoW', project: project) } diff --git a/spec/requests/api/issues_spec.rb b/spec/requests/api/issues_spec.rb index 2fc11a3b782..4d52ab2aaa2 100644 --- a/spec/requests/api/issues_spec.rb +++ b/spec/requests/api/issues_spec.rb @@ -928,29 +928,33 @@ describe API::Issues, api: true do ]) end - context 'resolving issues in a merge request' do + context 'resolving discussions' do let(:discussion) { Discussion.for_diff_notes([create(:diff_note_on_merge_request)]).first } let(:merge_request) { discussion.noteable } let(:project) { merge_request.source_project } + before do project.team << [user, :master] - post api("/projects/#{project.id}/issues", user), - title: 'New Issue', - merge_request_for_resolving_discussions: merge_request.iid - end - - it 'creates a new project issue' do - expect(response).to have_http_status(:created) end - it 'resolves the discussions in a merge request' do - discussion.first_note.reload + context 'resolving all discussions in a merge request' do + before do + post api("/projects/#{project.id}/issues", user), + title: 'New Issue', + merge_request_for_resolving_discussions: merge_request.iid + end - expect(discussion.resolved?).to be(true) + it_behaves_like 'creating an issue resolving discussions through the API' end - it 'assigns a description to the issue mentioning the merge request' do - expect(json_response['description']).to include(merge_request.to_reference) + context 'resolving a single discussion' do + before do + post api("/projects/#{project.id}/issues", user), + title: 'New Issue', + discussion_to_resolve: discussion.id + end + + it_behaves_like 'creating an issue resolving discussions through the API' end end diff --git a/spec/services/discussions/resolve_service_spec.rb b/spec/services/discussions/resolve_service_spec.rb index 12c3cdf28c6..5eb5b2b04d7 100644 --- a/spec/services/discussions/resolve_service_spec.rb +++ b/spec/services/discussions/resolve_service_spec.rb @@ -6,7 +6,7 @@ describe Discussions::ResolveService do let(:project) { merge_request.project } let(:merge_request) { discussion.noteable } let(:user) { create(:user) } - let(:service) { described_class.new(discussion.noteable.project, user, merge_request: merge_request) } + let(:service) { described_class.new(discussion.noteable.project, user) } before do project.team << [user, :master] diff --git a/spec/services/issues/base_service_spec.rb b/spec/services/issues/base_service_spec.rb new file mode 100644 index 00000000000..bfeefe3bcc5 --- /dev/null +++ b/spec/services/issues/base_service_spec.rb @@ -0,0 +1,112 @@ +require 'spec_helper.rb' + +describe Issues::BaseService, services: true do + let(:project) { create(:project) } + let(:user) { create(:user) } + + before do + project.team << [user, :developer] + end + + describe "for resolving discussions" do + let(:discussion) { Discussion.new([create(:diff_note_on_merge_request, project: project, note: "Almost done")]) } + let(:merge_request) { discussion.noteable } + let(:other_merge_request) { create(:merge_request, source_project: project, source_branch: "other") } + + describe "#for_single_discussion" do + it "is true when only a discussion is passed" do + service = described_class.new(project, user, discussion_to_resolve: discussion) + + expect(service.for_single_discussion?).to be_truthy + end + + it "is true when matching merge request and discussion are passed" do + service = described_class.new( + project, + user, + discussion_to_resolve: discussion, + merge_request_for_resolving_discussions: merge_request + ) + + expect(service.for_single_discussion?).to be_truthy + end + + it "is false when a discussion and another merge request are passed" do + service = described_class.new( + project, + user, + discussion_to_resolve: discussion, + merge_request_for_resolving_discussions: other_merge_request + ) + + expect(service.for_single_discussion?).to be_falsy + end + end + + describe "#for_all_discussions_in_a_merge_request" do + it "is true when only a merge request is passed" do + service = described_class.new(project, user, merge_request_for_resolving_discussions: merge_request) + + expect(service.for_all_discussions_in_a_merge_request?).to be_truthy + end + + it "is false when matching merge request and discussion are passed" do + service = described_class.new( + project, + user, + discussion_to_resolve: discussion, + merge_request_for_resolving_discussions: merge_request + ) + + expect(service.for_all_discussions_in_a_merge_request?).to be_falsy + end + end + + describe "#discussions_to_resolve" do + it "only contains a single discussion when only a discussion is passed" do + service = described_class.new(project, user, discussion_to_resolve: discussion) + + expect(service.discussions_to_resolve).to contain_exactly(discussion) + end + + it "is contains a single discussion when matching merge request and discussion are passed" do + service = described_class.new( + project, + user, + discussion_to_resolve: discussion, + merge_request_for_resolving_discussions: merge_request + ) + + expect(service.discussions_to_resolve).to contain_exactly(discussion) + end + + it "contains all discussions when only a merge request is passed" do + second_discussion = Discussion.new([create(:diff_note_on_merge_request, + noteable: merge_request, + project: merge_request.target_project, + line_number: 15)]) + service = described_class.new( + project, + user, + merge_request_for_resolving_discussions: merge_request + ) + # We need to compare discussion id's because the Discussion-objects are rebuilt + # which causes the object-id's not to be different. + discussion_ids = service.discussions_to_resolve.map(&:id) + + expect(discussion_ids).to contain_exactly(discussion.id, second_discussion.id) + end + + it "is empty when a discussion and another merge request are passed" do + service = described_class.new( + project, + user, + discussion_to_resolve: discussion, + merge_request_for_resolving_discussions: other_merge_request + ) + + expect(service.discussions_to_resolve).to be_empty + end + end + end +end diff --git a/spec/services/issues/build_service_spec.rb b/spec/services/issues/build_service_spec.rb index 09807e5d35b..cca7cdbdd2d 100644 --- a/spec/services/issues/build_service_spec.rb +++ b/spec/services/issues/build_service_spec.rb @@ -8,23 +8,36 @@ describe Issues::BuildService, services: true do project.team << [user, :developer] end + context 'for a single discussion' do + describe '#execute' do + it 'references the noteable title in the issue title' do + merge_request = create(:merge_request, title: "Hello world", source_project: project) + discussion = Discussion.new([create(:note_on_merge_request, project: project, noteable: merge_request)]) + service = described_class.new(project, user, discussion_to_resolve: discussion) + + issue = service.execute + + expect(issue.title).to include('Hello world') + end + + it 'adds the note content to the description' do + discussion = Discussion.new([create(:note_on_merge_request, project: project, note: "Almost done")]) + service = described_class.new(project, user, discussion_to_resolve: discussion) + + issue = service.execute + + expect(issue.description).to include('Almost done') + end + end + end + context 'for discussions in a merge request' do let(:merge_request) { create(:merge_request_with_diff_notes, source_project: project) } let(:issue) { described_class.new(project, user, merge_request_for_resolving_discussions: merge_request).execute } - def position_on_line(line_number) - Gitlab::Diff::Position.new( - old_path: "files/ruby/popen.rb", - new_path: "files/ruby/popen.rb", - old_line: nil, - new_line: line_number, - diff_refs: merge_request.diff_refs - ) - end - describe '#items_for_discussions' do it 'has an item for each discussion' do - create(:diff_note_on_merge_request, noteable: merge_request, project: merge_request.source_project, position: position_on_line(13)) + create(:diff_note_on_merge_request, noteable: merge_request, project: merge_request.source_project, line_number: 13) service = described_class.new(project, user, merge_request_for_resolving_discussions: merge_request) service.execute @@ -82,7 +95,7 @@ describe Issues::BuildService, services: true do describe 'with multiple discussions' do before do - create(:diff_note_on_merge_request, noteable: merge_request, project: merge_request.target_project, position: position_on_line(15)) + create(:diff_note_on_merge_request, noteable: merge_request, project: merge_request.target_project, line_number: 15) end it 'mentions all the authors in the description' do @@ -99,7 +112,7 @@ describe Issues::BuildService, services: true do end it 'mentions additional notes' do - create_list(:diff_note_on_merge_request, 2, noteable: merge_request, project: merge_request.target_project, position: position_on_line(15)) + create_list(:diff_note_on_merge_request, 2, noteable: merge_request, project: merge_request.target_project, line_number: 15) expect(issue.description).to include('(+2 comments)') end diff --git a/spec/services/issues/create_service_spec.rb b/spec/services/issues/create_service_spec.rb index 6045d00ff09..4a5780f257a 100644 --- a/spec/services/issues/create_service_spec.rb +++ b/spec/services/issues/create_service_spec.rb @@ -140,46 +140,85 @@ describe Issues::CreateService, services: true do it_behaves_like 'new issuable record that supports slash commands' - context 'for a merge request' do + context 'resolving discussions' do let(:discussion) { Discussion.for_diff_notes([create(:diff_note_on_merge_request)]).first } let(:merge_request) { discussion.noteable } let(:project) { merge_request.source_project } - let(:opts) { { merge_request_for_resolving_discussions: merge_request } } before do project.team << [user, :master] end - it 'resolves the discussion for the merge request' do - described_class.new(project, user, opts).execute - discussion.first_note.reload + describe 'for a single discussion' do + let(:opts) { { discussion_to_resolve: discussion } } - expect(discussion.resolved?).to be(true) - end + it 'resolves the discussion' do + described_class.new(project, user, opts).execute + discussion.first_note.reload - it 'added a system note to the discussion' do - described_class.new(project, user, opts).execute + expect(discussion.resolved?).to be(true) + end - reloaded_discussion = MergeRequest.find(merge_request.id).discussions.first + it 'added a system note to the discussion' do + described_class.new(project, user, opts).execute - expect(reloaded_discussion.last_note.system).to eq(true) - end + reloaded_discussion = MergeRequest.find(merge_request.id).discussions.first + + expect(reloaded_discussion.last_note.system).to eq(true) + end + + it 'assigns the title and description for the issue' do + issue = described_class.new(project, user, opts).execute + + expect(issue.title).not_to be_nil + expect(issue.description).not_to be_nil + end - it 'assigns the title and description for the issue' do - issue = described_class.new(project, user, opts).execute + it 'can set nil explicitly to the title and description' do + issue = described_class.new(project, user, + merge_request_for_resolving_discussions: merge_request, + description: nil, + title: nil).execute - expect(issue.title).not_to be_nil - expect(issue.description).not_to be_nil + expect(issue.description).to be_nil + expect(issue.title).to be_nil + end end - it 'can set nil explicityly to the title and description' do - issue = described_class.new(project, user, - merge_request_for_resolving_discussions: merge_request, - description: nil, - title: nil).execute + describe 'for a merge request' do + let(:opts) { { merge_request_for_resolving_discussions: merge_request } } + + it 'resolves the discussion' do + described_class.new(project, user, opts).execute + discussion.first_note.reload - expect(issue.description).to be_nil - expect(issue.title).to be_nil + expect(discussion.resolved?).to be(true) + end + + it 'added a system note to the discussion' do + described_class.new(project, user, opts).execute + + reloaded_discussion = MergeRequest.find(merge_request.id).discussions.first + + expect(reloaded_discussion.last_note.system).to eq(true) + end + + it 'assigns the title and description for the issue' do + issue = described_class.new(project, user, opts).execute + + expect(issue.title).not_to be_nil + expect(issue.description).not_to be_nil + end + + it 'can set nil explicitly to the title and description' do + issue = described_class.new(project, user, + merge_request_for_resolving_discussions: merge_request, + description: nil, + title: nil).execute + + expect(issue.description).to be_nil + expect(issue.title).to be_nil + end end end diff --git a/spec/support/api/issues_resolving_discussions_shared_examples.rb b/spec/support/api/issues_resolving_discussions_shared_examples.rb new file mode 100644 index 00000000000..d26d279363c --- /dev/null +++ b/spec/support/api/issues_resolving_discussions_shared_examples.rb @@ -0,0 +1,15 @@ +shared_examples 'creating an issue resolving discussions through the API' do + it 'creates a new project issue' do + expect(response).to have_http_status(:created) + end + + it 'resolves the discussions in a merge request' do + discussion.first_note.reload + + expect(discussion.resolved?).to be(true) + end + + it 'assigns a description to the issue mentioning the merge request' do + expect(json_response['description']).to include(merge_request.to_reference) + end +end diff --git a/spec/support/features/resolving_discussions_in_issues_shared_examples.rb b/spec/support/features/resolving_discussions_in_issues_shared_examples.rb new file mode 100644 index 00000000000..8712e688d33 --- /dev/null +++ b/spec/support/features/resolving_discussions_in_issues_shared_examples.rb @@ -0,0 +1,25 @@ +shared_examples 'creating an issue for a discussion' do + it 'shows an issue with the title filled in' do + title_field = page.find_field('issue[title]') + + expect(title_field.value).to include(merge_request.title) + end + + it 'has a mention of the discussion in the description' do + description_field = page.find_field('issue[description]') + + expect(description_field.value).to include(discussion.first_note.note) + end + + it 'can create a new issue for the project' do + expect { click_button 'Submit issue' }.to change { project.issues.reload.size }.by(1) + end + + it 'resolves the discussion in the merge request' do + click_button 'Submit issue' + + discussion.first_note.reload + + expect(discussion.resolved?).to eq(true) + end +end -- cgit v1.2.1 From 51253b2dd0bb073d271ddcbd172f7c204d4639db Mon Sep 17 00:00:00 2001 From: Bob Van Landuyt Date: Mon, 27 Feb 2017 09:38:12 +0100 Subject: Simplify NotesFinder discussion query --- app/finders/notes_finder.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/finders/notes_finder.rb b/app/finders/notes_finder.rb index c93e6b05a67..6d0ff1798ce 100644 --- a/app/finders/notes_finder.rb +++ b/app/finders/notes_finder.rb @@ -108,6 +108,6 @@ class NotesFinder end def for_discussion(discussion_id) - @notes.where(Note.arel_table[:discussion_id].eq(discussion_id)) + @notes.where(discussion_id: discussion_id) end end -- cgit v1.2.1 From f86928953d2d79f40f10813a6e244c1da0779d16 Mon Sep 17 00:00:00 2001 From: Bob Van Landuyt Date: Mon, 27 Feb 2017 10:23:36 +0100 Subject: Always require MR-iid for resolving discussions And deduplicate the finding of MR's & discussions. Now the searching is done in the service, istead of the controller & the API. --- app/controllers/projects/issues_controller.rb | 36 +++------ app/finders/notes_finder.rb | 4 - app/services/issues/base_service.rb | 36 ++++----- app/services/issues/create_service.rb | 4 +- .../_new_issue_for_discussion.html.haml | 2 +- app/views/discussions/_notes.html.haml | 2 +- app/views/shared/issuable/_form.html.haml | 26 ++----- doc/api/issues.md | 6 +- lib/api/issues.rb | 13 +--- .../controllers/projects/issues_controller_spec.rb | 13 +++- ...issue_for_single_discussion_in_merge_request.rb | 4 +- spec/finders/notes_finder_spec.rb | 22 ------ spec/requests/api/issues_spec.rb | 1 + spec/services/issues/base_service_spec.rb | 90 +++++++--------------- spec/services/issues/build_service_spec.rb | 19 ++--- spec/services/issues/create_service_spec.rb | 4 +- ...olving_discussions_in_issues_shared_examples.rb | 6 ++ 17 files changed, 101 insertions(+), 187 deletions(-) diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index d7eb73d7ff5..d76643ff875 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -66,11 +66,14 @@ class Projects::IssuesController < Projects::ApplicationController ) build_params = issue_params.merge( - merge_request_for_resolving_discussions: merge_request_for_resolving_discussions, - discussion_to_resolve: discussion_to_resolve + merge_request_for_resolving_discussions: params[:merge_request_for_resolving_discussions], + discussion_to_resolve: params[:discussion_to_resolve] ) + service = Issues::BuildService.new(project, current_user, build_params) + @merge_request_for_resolving_discussions = service.merge_request_for_resolving_discussions + @discussion_to_resolve = service.discussions_to_resolve.first if params[:discussion_to_resolve] - @issue = @noteable = Issues::BuildService.new(project, current_user, build_params).execute + @issue = @noteable = service.execute respond_with(@issue) end @@ -99,12 +102,11 @@ class Projects::IssuesController < Projects::ApplicationController end def create - create_params = issue_params. - merge( - merge_request_for_resolving_discussions: merge_request_for_resolving_discussions, - discussion_to_resolve: discussion_to_resolve - ). - merge(spammable_params) + create_params = issue_params.merge(spammable_params).merge( + merge_request_for_resolving_discussions: params[:merge_request_for_resolving_discussions], + discussion_to_resolve: params[:discussion_to_resolve] + ) + @issue = Issues::CreateService.new(project, current_user, create_params).execute respond_to do |format| @@ -192,21 +194,6 @@ class Projects::IssuesController < Projects::ApplicationController alias_method :awardable, :issue alias_method :spammable, :issue - def merge_request_for_resolving_discussions - return unless merge_request_iid = params[:merge_request_for_resolving_discussions] - - @merge_request_for_resolving_discussions ||= MergeRequestsFinder.new(current_user, project_id: project.id). - execute. - find_by(iid: merge_request_iid) - end - - def discussion_to_resolve - return unless discussion_id = params[:discussion_to_resolve] - - @discussion_to_resolve ||= NotesFinder.new(project, current_user, discussion_id: discussion_id). - first_discussion - end - def authorize_read_issue! return render_404 unless can?(current_user, :read_issue, @issue) end @@ -253,6 +240,7 @@ class Projects::IssuesController < Projects::ApplicationController def issue_params params.require(:issue).permit( :title, :assignee_id, :position, :description, :confidential, + :discussion_to_resolve, :merge_request_for_resolving_discussions, :milestone_id, :due_date, :state_event, :task_num, :lock_version, label_ids: [] ) end diff --git a/app/finders/notes_finder.rb b/app/finders/notes_finder.rb index 6d0ff1798ce..98c66dfa021 100644 --- a/app/finders/notes_finder.rb +++ b/app/finders/notes_finder.rb @@ -27,10 +27,6 @@ class NotesFinder @notes end - def first_discussion - execute.discussions.first - end - private def init_collection diff --git a/app/services/issues/base_service.rb b/app/services/issues/base_service.rb index 3aa842eb9a8..058c398df02 100644 --- a/app/services/issues/base_service.rb +++ b/app/services/issues/base_service.rb @@ -1,12 +1,11 @@ module Issues class BaseService < ::IssuableBaseService - attr_reader :merge_request_for_resolving_discussions, :discussion_to_resolve - + attr_reader :merge_request_for_resolving_discussions_iid, :discussion_to_resolve_id def initialize(*args) super - @merge_request_for_resolving_discussions ||= params.delete(:merge_request_for_resolving_discussions) - @discussion_to_resolve ||= params.delete(:discussion_to_resolve) + @merge_request_for_resolving_discussions_iid ||= params.delete(:merge_request_for_resolving_discussions) + @discussion_to_resolve_id ||= params.delete(:discussion_to_resolve) end def hook_data(issue, action) @@ -17,25 +16,22 @@ module Issues end def merge_request_for_resolving_discussions - @merge_request_for_resolving_discussions ||= discussion_to_resolve.try(:noteable) - end - - def for_all_discussions_in_a_merge_request? - discussion_to_resolve.nil? && merge_request_for_resolving_discussions - end - - def for_single_discussion? - discussion_to_resolve && discussion_to_resolve.noteable == merge_request_for_resolving_discussions + @merge_request_for_resolving_discussions ||= MergeRequestsFinder.new(current_user, project_id: project.id). + execute. + find_by(iid: merge_request_for_resolving_discussions_iid) end def discussions_to_resolve - @discussions_to_resolve ||= if for_all_discussions_in_a_merge_request? - merge_request_for_resolving_discussions.resolvable_discussions - elsif for_single_discussion? - Array(discussion_to_resolve) - else - [] - end + return [] unless merge_request_for_resolving_discussions + + @discussions_to_resolve ||= NotesFinder.new(project, current_user, { + discussion_id: discussion_to_resolve_id, + target_type: MergeRequest.name.underscore, + target_id: merge_request_for_resolving_discussions.id + }). + execute. + discussions. + select(&:to_be_resolved?) end private diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb index d2cc70ed0dc..007c8354c8c 100644 --- a/app/services/issues/create_service.rb +++ b/app/services/issues/create_service.rb @@ -6,8 +6,8 @@ module Issues filter_spam_check_params issue_attributes = params.merge( - merge_request_for_resolving_discussions: merge_request_for_resolving_discussions, - discussion_to_resolve: discussion_to_resolve + merge_request_for_resolving_discussions: merge_request_for_resolving_discussions_iid, + discussion_to_resolve: discussion_to_resolve_id ) @issue = BuildService.new(project, current_user, issue_attributes).execute diff --git a/app/views/discussions/_new_issue_for_discussion.html.haml b/app/views/discussions/_new_issue_for_discussion.html.haml index de28ded9820..c21ec0ecc3d 100644 --- a/app/views/discussions/_new_issue_for_discussion.html.haml +++ b/app/views/discussions/_new_issue_for_discussion.html.haml @@ -5,4 +5,4 @@ .btn.btn-default.discussion-create-issue-btn.has-tooltip{ title: "Resolve this discussion in a new issue", "aria-label" => "Resolve this discussion in a new issue", "data-container" => "body" } - = link_to custom_icon('icon_mr_issue'), new_namespace_project_issue_path(@project.namespace, @project, discussion_to_resolve: discussion.id), title: "Resolve this discussion in a new issue", class: 'new-issue-for-discussion' + = link_to custom_icon('icon_mr_issue'), new_namespace_project_issue_path(@project.namespace, @project, merge_request_for_resolving_discussions: merge_request.iid, discussion_to_resolve: discussion.id), title: "Resolve this discussion in a new issue", class: 'new-issue-for-discussion' diff --git a/app/views/discussions/_notes.html.haml b/app/views/discussions/_notes.html.haml index 4a1e93cec3a..2789391819c 100644 --- a/app/views/discussions/_notes.html.haml +++ b/app/views/discussions/_notes.html.haml @@ -12,7 +12,7 @@ = render "discussions/resolve_all", discussion: discussion - if discussion.for_merge_request? .btn-group.discussion-actions - = render "discussions/new_issue_for_discussion", discussion: discussion + = render "discussions/new_issue_for_discussion", discussion: discussion, merge_request: discussion.noteable = render "discussions/jump_to_next", discussion: discussion - else = link_to_reply_discussion(discussion) diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index e93fe0f6cb5..a10f9caf3c1 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -50,30 +50,20 @@ .col-sm-10.col-sm-offset-2 = icon('exclamation-triangle') - if @merge_request_for_resolving_discussions.discussions_can_be_resolved_by?(current_user) - Creating this issue will mark all discussions in - = link_to @merge_request_for_resolving_discussions.to_reference, merge_request_path(@merge_request_for_resolving_discussions) - as resolved. = hidden_field_tag 'merge_request_for_resolving_discussions', @merge_request_for_resolving_discussions.iid + - if @discussion_to_resolve + = hidden_field_tag 'discussion_to_resolve', @discussion_to_resolve.id + Creating this issue will mark the discussion at + = link_to @discussion_to_resolve.noteable.to_reference, Gitlab::UrlBuilder.build(@discussion_to_resolve.first_note) + - else + Creating this issue will mark all discussions in + = link_to @merge_request_for_resolving_discussions.to_reference, merge_request_path(@merge_request_for_resolving_discussions) + as resolved. - else You can't automatically mark all discussions in = link_to @merge_request_for_resolving_discussions.to_reference, merge_request_path(@merge_request_for_resolving_discussions) as resolved. Ask someone with sufficient rights to resolve the them. -- if @discussion_to_resolve - .form-group - .col-sm-10.col-sm-offset-2 - = icon('exclamation-triangle') - - if @discussion_to_resolve.can_resolve?(current_user) - Creating this issue will mark the discussion at - = link_to @discussion_to_resolve.noteable.to_reference, Gitlab::UrlBuilder.build(@discussion_to_resolve.first_note) - as resolved. - = hidden_field_tag 'discussion_to_resolve', @discussion_to_resolve.id - - else - You can't automatically mark the discussion at - = link_to @discussion_to_resolve.noteable.to_reference, Gitlab::UrlBuilder.build(@discussion_to_resolve.first_note) - as resolved. Ask someone with sufficient rights to resolve it. - - - is_footer = !(issuable.is_a?(MergeRequest) && issuable.new_record?) .row-content-block{ class: (is_footer ? "footer-block" : "middle-block") } .pull-right diff --git a/doc/api/issues.md b/doc/api/issues.md index ddc304ea128..05fb9739ed1 100644 --- a/doc/api/issues.md +++ b/doc/api/issues.md @@ -329,8 +329,8 @@ Creates a new project issue. POST /projects/:id/issues ``` -| Attribute | Type | Required | Description | -| --------- | ---- | -------- | ----------- | +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | | `id` | integer | yes | The ID of a project | | `title` | string | yes | The title of an issue | | `description` | string | no | The description of an issue | @@ -341,7 +341,7 @@ POST /projects/:id/issues | `created_at` | string | no | Date time string, ISO 8601 formatted, e.g. `2016-03-11T03:45:40Z` (requires admin or project owner rights) | | `due_date` | string | no | Date time string in the format YEAR-MONTH-DAY, e.g. `2016-03-11` | | `merge_request_for_resolving_discussions` | integer | no | The IID of a merge request in which to resolve all issues. This will fill the issue with a default description and mark all discussions as resolved. When passing a description or title, these values will take precedence over the default values. | -| `discussion_to_resolve` | string | no | The ID of a discussion to resolve. This will fill in the issue with a default description and mark the discussion as resolved. | +| `discussion_to_resolve` | string | no | The ID of a discussion to resolve. This will fill in the issue with a default description and mark the discussion as resolved. Use in combination with `merge_request_for_resolving_discussions`. | ```bash curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/4/issues?title=Issues%20with%20auth&labels=bug diff --git a/lib/api/issues.rb b/lib/api/issues.rb index 9aac3c8fe1a..162e13a313d 100644 --- a/lib/api/issues.rb +++ b/lib/api/issues.rb @@ -119,7 +119,7 @@ module API optional :merge_request_for_resolving_discussions, type: Integer, desc: 'The IID of a merge request for which to resolve discussions' optional :discussion_to_resolve, type: String, - desc: 'The ID of a discussion to resolve' + desc: 'The ID of a discussion to resolve, also pass `merge_request_for_resolving_discussions`' use :issue_params end post ':id/issues' do @@ -130,17 +130,6 @@ module API issue_params = declared_params(include_missing: false) - if merge_request_iid = params[:merge_request_for_resolving_discussions] - issue_params[:merge_request_for_resolving_discussions] = MergeRequestsFinder.new(current_user, project_id: user_project.id). - execute. - find_by(iid: merge_request_iid) - end - - if discussion_id = params[:discussion_to_resolve] - issue_params[:discussion_to_resolve] = NotesFinder.new(user_project, current_user, discussion_id: discussion_id). - first_discussion - end - issue = ::Issues::CreateService.new(user_project, current_user, issue_params.merge(request: request, api: true)).execute diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb index 336e3b50a72..46e2f4cef29 100644 --- a/spec/controllers/projects/issues_controller_spec.rb +++ b/spec/controllers/projects/issues_controller_spec.rb @@ -113,7 +113,7 @@ describe Projects::IssuesController do it 'fills in an issue for a discussion' do note = create(:note_on_merge_request, project: project) - get :new, namespace_id: project.namespace.path, project_id: project, discussion_to_resolve: note.discussion_id + get :new, namespace_id: project.namespace.path, project_id: project, merge_request_for_resolving_discussions: note.noteable.iid, discussion_to_resolve: note.discussion_id expect(assigns(:issue).title).not_to be_empty expect(assigns(:issue).description).not_to be_empty @@ -474,8 +474,8 @@ describe Projects::IssuesController do { merge_request_for_resolving_discussions: merge_request.iid } end - def post_issue(issue_params) - post :create, namespace_id: project.namespace.to_param, project_id: project, issue: issue_params, merge_request_for_resolving_discussions: merge_request.iid + def post_issue(issue_params, other_params: {}) + post :create, { namespace_id: project.namespace.to_param, project_id: project, issue: issue_params, merge_request_for_resolving_discussions: merge_request.iid }.merge(other_params) end it 'creates an issue for the project' do @@ -494,6 +494,13 @@ describe Projects::IssuesController do expect(discussion.resolved?).to eq(true) end + + it "resolves a single discussion" do + post_issue(other_params: { discussion_to_resolve: discussion.id }) + discussion.first_note.reload + + expect(discussion.resolved?).to eq(true) + end end context 'Akismet is enabled' do diff --git a/spec/features/issues/create_issue_for_single_discussion_in_merge_request.rb b/spec/features/issues/create_issue_for_single_discussion_in_merge_request.rb index 6cf67639b7e..61b5b226c03 100644 --- a/spec/features/issues/create_issue_for_single_discussion_in_merge_request.rb +++ b/spec/features/issues/create_issue_for_single_discussion_in_merge_request.rb @@ -42,14 +42,14 @@ feature 'Resolve an open discussion in a merge request by creating an issue', fe end it 'has a link to create a new issue for a discussion' do - new_issue_link = new_namespace_project_issue_path(project.namespace, project, discussion_to_resolve: discussion.id) + new_issue_link = new_namespace_project_issue_path(project.namespace, project, discussion_to_resolve: discussion.id, merge_request_for_resolving_discussions: merge_request.iid) expect(page).to have_link 'Resolve this discussion in a new issue', href: new_issue_link end context 'creating the issue' do before do - click_link 'Resolve this discussion in a new issue', href: new_namespace_project_issue_path(project.namespace, project, discussion_to_resolve: discussion.id) + click_link 'Resolve this discussion in a new issue', href: new_namespace_project_issue_path(project.namespace, project, discussion_to_resolve: discussion.id, merge_request_for_resolving_discussions: merge_request.iid) end it 'has a hidden field for the discussion' do diff --git a/spec/finders/notes_finder_spec.rb b/spec/finders/notes_finder_spec.rb index 5fc2e3dd527..2c72f7839a6 100644 --- a/spec/finders/notes_finder_spec.rb +++ b/spec/finders/notes_finder_spec.rb @@ -157,28 +157,6 @@ describe NotesFinder do end end - describe "#first_discussion" do - let!(:discussion_note) { create(:note_on_merge_request, project: project) } - - it 'returns a discussion' do - discussion = described_class.new(project, user, discussion_id: discussion_note.discussion_id).first_discussion - - expect(discussion).is_a? Discussion - end - - it 'includes the notes in the discussion' do - discussion = described_class.new(project, user, discussion_id: discussion_note.discussion_id).first_discussion - - expect(discussion.notes).to include(discussion_note) - end - - it 'returns nil when no notes are found' do - discussion = described_class.new(project, user, discussion_id: 'non-existant').first_discussion - - expect(discussion).to be(nil) - end - end - describe '.search' do let(:project) { create(:empty_project, :public) } let(:note) { create(:note_on_issue, note: 'WoW', project: project) } diff --git a/spec/requests/api/issues_spec.rb b/spec/requests/api/issues_spec.rb index 4d52ab2aaa2..26997ac0107 100644 --- a/spec/requests/api/issues_spec.rb +++ b/spec/requests/api/issues_spec.rb @@ -951,6 +951,7 @@ describe API::Issues, api: true do before do post api("/projects/#{project.id}/issues", user), title: 'New Issue', + merge_request_for_resolving_discussions: merge_request.iid, discussion_to_resolve: discussion.id end diff --git a/spec/services/issues/base_service_spec.rb b/spec/services/issues/base_service_spec.rb index bfeefe3bcc5..0cef09917ee 100644 --- a/spec/services/issues/base_service_spec.rb +++ b/spec/services/issues/base_service_spec.rb @@ -13,71 +13,19 @@ describe Issues::BaseService, services: true do let(:merge_request) { discussion.noteable } let(:other_merge_request) { create(:merge_request, source_project: project, source_branch: "other") } - describe "#for_single_discussion" do - it "is true when only a discussion is passed" do - service = described_class.new(project, user, discussion_to_resolve: discussion) - - expect(service.for_single_discussion?).to be_truthy - end - - it "is true when matching merge request and discussion are passed" do - service = described_class.new( - project, - user, - discussion_to_resolve: discussion, - merge_request_for_resolving_discussions: merge_request - ) - - expect(service.for_single_discussion?).to be_truthy - end - - it "is false when a discussion and another merge request are passed" do - service = described_class.new( - project, - user, - discussion_to_resolve: discussion, - merge_request_for_resolving_discussions: other_merge_request - ) - - expect(service.for_single_discussion?).to be_falsy - end - end - - describe "#for_all_discussions_in_a_merge_request" do - it "is true when only a merge request is passed" do - service = described_class.new(project, user, merge_request_for_resolving_discussions: merge_request) - - expect(service.for_all_discussions_in_a_merge_request?).to be_truthy - end - - it "is false when matching merge request and discussion are passed" do - service = described_class.new( - project, - user, - discussion_to_resolve: discussion, - merge_request_for_resolving_discussions: merge_request - ) - - expect(service.for_all_discussions_in_a_merge_request?).to be_falsy - end - end - describe "#discussions_to_resolve" do - it "only contains a single discussion when only a discussion is passed" do - service = described_class.new(project, user, discussion_to_resolve: discussion) - - expect(service.discussions_to_resolve).to contain_exactly(discussion) - end - - it "is contains a single discussion when matching merge request and discussion are passed" do + it "contains a single discussion when matching merge request and discussion are passed" do service = described_class.new( project, user, - discussion_to_resolve: discussion, - merge_request_for_resolving_discussions: merge_request + discussion_to_resolve: discussion.id, + merge_request_for_resolving_discussions: merge_request.iid ) + # We need to compare discussion id's because the Discussion-objects are rebuilt + # which causes the object-id's not to be different. + discussion_ids = service.discussions_to_resolve.map(&:id) - expect(service.discussions_to_resolve).to contain_exactly(discussion) + expect(discussion_ids).to contain_exactly(discussion.id) end it "contains all discussions when only a merge request is passed" do @@ -88,7 +36,7 @@ describe Issues::BaseService, services: true do service = described_class.new( project, user, - merge_request_for_resolving_discussions: merge_request + merge_request_for_resolving_discussions: merge_request.iid ) # We need to compare discussion id's because the Discussion-objects are rebuilt # which causes the object-id's not to be different. @@ -97,12 +45,30 @@ describe Issues::BaseService, services: true do expect(discussion_ids).to contain_exactly(discussion.id, second_discussion.id) end + it "contains only unresolved discussions" do + second_discussion = Discussion.new([create(:diff_note_on_merge_request, :resolved, + noteable: merge_request, + project: merge_request.target_project, + line_number: 15, + )]) + service = described_class.new( + project, + user, + merge_request_for_resolving_discussions: merge_request.iid + ) + # We need to compare discussion id's because the Discussion-objects are rebuilt + # which causes the object-id's not to be different. + discussion_ids = service.discussions_to_resolve.map(&:id) + + expect(discussion_ids).to contain_exactly(discussion.id) + end + it "is empty when a discussion and another merge request are passed" do service = described_class.new( project, user, - discussion_to_resolve: discussion, - merge_request_for_resolving_discussions: other_merge_request + discussion_to_resolve: discussion.id, + merge_request_for_resolving_discussions: other_merge_request.iid ) expect(service.discussions_to_resolve).to be_empty diff --git a/spec/services/issues/build_service_spec.rb b/spec/services/issues/build_service_spec.rb index cca7cdbdd2d..76417279923 100644 --- a/spec/services/issues/build_service_spec.rb +++ b/spec/services/issues/build_service_spec.rb @@ -10,20 +10,17 @@ describe Issues::BuildService, services: true do context 'for a single discussion' do describe '#execute' do - it 'references the noteable title in the issue title' do - merge_request = create(:merge_request, title: "Hello world", source_project: project) - discussion = Discussion.new([create(:note_on_merge_request, project: project, noteable: merge_request)]) - service = described_class.new(project, user, discussion_to_resolve: discussion) + let(:merge_request) { create(:merge_request, title: "Hello world", source_project: project) } + let(:discussion) { Discussion.new([create(:diff_note_on_merge_request, project: project, noteable: merge_request, note: "Almost done")]) } + let(:service) { described_class.new(project, user, merge_request_for_resolving_discussions: merge_request.iid, discussion_to_resolve: discussion.id) } + it 'references the noteable title in the issue title' do issue = service.execute expect(issue.title).to include('Hello world') end it 'adds the note content to the description' do - discussion = Discussion.new([create(:note_on_merge_request, project: project, note: "Almost done")]) - service = described_class.new(project, user, discussion_to_resolve: discussion) - issue = service.execute expect(issue.description).to include('Almost done') @@ -33,12 +30,12 @@ describe Issues::BuildService, services: true do context 'for discussions in a merge request' do let(:merge_request) { create(:merge_request_with_diff_notes, source_project: project) } - let(:issue) { described_class.new(project, user, merge_request_for_resolving_discussions: merge_request).execute } + let(:issue) { described_class.new(project, user, merge_request_for_resolving_discussions: merge_request.iid).execute } describe '#items_for_discussions' do it 'has an item for each discussion' do create(:diff_note_on_merge_request, noteable: merge_request, project: merge_request.source_project, line_number: 13) - service = described_class.new(project, user, merge_request_for_resolving_discussions: merge_request) + service = described_class.new(project, user, merge_request_for_resolving_discussions: merge_request.iid) service.execute @@ -47,7 +44,7 @@ describe Issues::BuildService, services: true do end describe '#item_for_discussion' do - let(:service) { described_class.new(project, user, merge_request_for_resolving_discussions: merge_request) } + let(:service) { described_class.new(project, user, merge_request_for_resolving_discussions: merge_request.iid) } it 'mentions the author of the note' do discussion = Discussion.new([create(:diff_note_on_merge_request, author: create(:user, username: 'author'))]) @@ -125,7 +122,7 @@ describe Issues::BuildService, services: true do describe '#execute' do it 'mentions the merge request in the description' do - issue = described_class.new(project, user, merge_request_for_resolving_discussions: merge_request).execute + issue = described_class.new(project, user, merge_request_for_resolving_discussions: merge_request.iid).execute expect(issue.description).to include("Review the conversation in #{merge_request.to_reference}") end diff --git a/spec/services/issues/create_service_spec.rb b/spec/services/issues/create_service_spec.rb index 4a5780f257a..77a984df2d5 100644 --- a/spec/services/issues/create_service_spec.rb +++ b/spec/services/issues/create_service_spec.rb @@ -150,7 +150,7 @@ describe Issues::CreateService, services: true do end describe 'for a single discussion' do - let(:opts) { { discussion_to_resolve: discussion } } + let(:opts) { { discussion_to_resolve: discussion.id, merge_request_for_resolving_discussions: merge_request.iid } } it 'resolves the discussion' do described_class.new(project, user, opts).execute @@ -186,7 +186,7 @@ describe Issues::CreateService, services: true do end describe 'for a merge request' do - let(:opts) { { merge_request_for_resolving_discussions: merge_request } } + let(:opts) { { merge_request_for_resolving_discussions: merge_request.iid } } it 'resolves the discussion' do described_class.new(project, user, opts).execute diff --git a/spec/support/features/resolving_discussions_in_issues_shared_examples.rb b/spec/support/features/resolving_discussions_in_issues_shared_examples.rb index 8712e688d33..669b306d94e 100644 --- a/spec/support/features/resolving_discussions_in_issues_shared_examples.rb +++ b/spec/support/features/resolving_discussions_in_issues_shared_examples.rb @@ -22,4 +22,10 @@ shared_examples 'creating an issue for a discussion' do expect(discussion.resolved?).to eq(true) end + + it 'has a hidden field for the merge request' do + merge_request_field = find('#merge_request_for_resolving_discussions', visible: false) + + expect(merge_request_field.value).to eq(merge_request.iid.to_s) + end end -- cgit v1.2.1 From d6453e75b7100ee94c3d125d21627961adcaa8cc Mon Sep 17 00:00:00 2001 From: Bob Van Landuyt Date: Mon, 27 Feb 2017 16:01:56 +0100 Subject: Add a button to delegate all discussions in a merge request --- app/assets/stylesheets/pages/notes.scss | 20 +++++++ .../_new_issue_for_all_discussions.html.haml | 6 ++ app/views/projects/merge_requests/_show.html.haml | 1 + .../img/btn_new_issue_for_all_discussions.png | Bin 0 -> 29007 bytes .../merge_request_discussion_resolution.md | 14 +++-- ..._issue_for_discussions_in_merge_request_spec.rb | 65 ++++++++++++++------- 6 files changed, 82 insertions(+), 24 deletions(-) create mode 100644 app/views/discussions/_new_issue_for_all_discussions.html.haml create mode 100644 doc/user/project/merge_requests/img/btn_new_issue_for_all_discussions.png diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index dc79de19d48..71005a4666e 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -510,6 +510,7 @@ ul.notes { } .line-resolve-all-container { + .btn-group { margin-left: -4px; } @@ -518,6 +519,25 @@ ul.notes { border-top-left-radius: 0; border-bottom-left-radius: 0; } + + .btn.discussion-create-issue-btn { + margin-left: 4px; + + a { + padding: 0; + line-height: 0; + + &:hover { + text-decoration: none; + border: 0; + } + } + + .new-issue-for-discussion path { + fill: $gray-darkest; + } + } + } .line-resolve-all { diff --git a/app/views/discussions/_new_issue_for_all_discussions.html.haml b/app/views/discussions/_new_issue_for_all_discussions.html.haml new file mode 100644 index 00000000000..21fbbbee54b --- /dev/null +++ b/app/views/discussions/_new_issue_for_all_discussions.html.haml @@ -0,0 +1,6 @@ +- if merge_request.discussions_can_be_resolved_by?(current_user) && can?(current_user, :create_issue, @project) + .btn-group{ role: "group", "v-if" => "unresolvedDiscussionCount > 0" } + .btn.btn-default.discussion-create-issue-btn.has-tooltip{ title: "Resolve all discussions in new issue", + "aria-label" => "Resolve all discussions in new issue", + "data-container" => "body" } + = link_to custom_icon('icon_mr_issue'), new_namespace_project_issue_path(@project.namespace, @project, merge_request_for_resolving_discussions: merge_request.iid), title: "Resolve all discussions in new issue", class: 'new-issue-for-discussion' diff --git a/app/views/projects/merge_requests/_show.html.haml b/app/views/projects/merge_requests/_show.html.haml index 17be0490a86..524cec5e1f9 100644 --- a/app/views/projects/merge_requests/_show.html.haml +++ b/app/views/projects/merge_requests/_show.html.haml @@ -83,6 +83,7 @@ %span.line-resolve-text {{ resolvedDiscussionCount }}/{{ discussionCount }} {{ resolvedCountText }} resolved = render "discussions/jump_to_next" + = render "discussions/new_issue_for_all_discussions", merge_request: @merge_request .tab-content#diff-notes-app #notes.notes.tab-pane.voting_notes diff --git a/doc/user/project/merge_requests/img/btn_new_issue_for_all_discussions.png b/doc/user/project/merge_requests/img/btn_new_issue_for_all_discussions.png new file mode 100644 index 00000000000..b15447ec290 Binary files /dev/null and b/doc/user/project/merge_requests/img/btn_new_issue_for_all_discussions.png differ diff --git a/doc/user/project/merge_requests/merge_request_discussion_resolution.md b/doc/user/project/merge_requests/merge_request_discussion_resolution.md index 63274c86bbb..230e957f045 100644 --- a/doc/user/project/merge_requests/merge_request_discussion_resolution.md +++ b/doc/user/project/merge_requests/merge_request_discussion_resolution.md @@ -53,12 +53,18 @@ are resolved. ## Move all unresolved discussions in a merge request to an issue -> [Introduced][ce-7180] in GitLab 8.15. +> [Introduced][ce-8266] + +To continue all open discussions in a merge request, click the button **Resolve +all discussions in new issue** + +![Open new issue for all unresolved discussions](img/btn_new_issue_for_all_discussions.png) -To delegate unresolved discussions to a new issue you can click the link **open -an issue to resolve them later**. +Alternatively, when your project only accepts merge requests when all discussions +are resolved, there will be an **open an issue to resolve them later** link in +the merge request-widget. -![Open new issue from unresolved discussions](img/resolve_discussion_open_issue.png) +![Link in merge request widget](img/resolve_discussion_open_issue.png) This will prepare an issue with content referring to the merge request and discussions. diff --git a/spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb b/spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb index 62ecd0100ae..4751b16013d 100644 --- a/spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb +++ b/spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb @@ -1,54 +1,79 @@ require 'rails_helper' -feature 'Resolving all open discussions in a merge request from an issue', feature: true do +feature 'Resolving all open discussions in a merge request from an issue', feature: true, js: true do let(:user) { create(:user) } - let(:project) { create(:project, only_allow_merge_if_all_discussions_are_resolved: true) } + let(:project) { create(:project) } let(:merge_request) { create(:merge_request, source_project: project) } let!(:discussion) { Discussion.for_diff_notes([create(:diff_note_on_merge_request, noteable: merge_request, project: project)]).first } before do project.team << [user, :master] login_as user + visit namespace_project_merge_request_path(project.namespace, project, merge_request) end - context 'with the internal tracker disabled' do + it 'shows a button to resolve all discussions by creating a new issue' do + within('li#resolve-count-app') do + expect(page).to have_link "Resolve all discussions in new issue", href: new_namespace_project_issue_path(project.namespace, project, merge_request_for_resolving_discussions: merge_request.iid) + end + end + + context 'resolving the discussion' do before do - project.project_feature.update_attribute(:issues_access_level, ProjectFeature::DISABLED) - visit namespace_project_merge_request_path(project.namespace, project, merge_request) + click_button 'Resolve discussion' end - it 'does not show a link to create a new issue' do - expect(page).not_to have_link 'open an issue to resolve them later' + it 'hides the link for creating a new issue' do + expect(page).not_to have_link "Resolve all discussions in new issue", href: new_namespace_project_issue_path(project.namespace, project, merge_request_for_resolving_discussions: merge_request.iid) end end - context 'merge request has discussions that need to be resolved' do + context 'creating an issue for discussions' do before do - visit namespace_project_merge_request_path(project.namespace, project, merge_request) + click_link "Resolve all discussions in new issue", href: new_namespace_project_issue_path(project.namespace, project, merge_request_for_resolving_discussions: merge_request.iid) end - it 'shows a warning that the merge request contains unresolved discussions' do - expect(page).to have_content 'This merge request has unresolved discussions' + it_behaves_like 'creating an issue for a discussion' + end + + context 'for a project where all discussions need to be resolved before merging' do + before do + project.update_attribute(:only_allow_merge_if_all_discussions_are_resolved, true) end - it 'has a link to resolve all discussions by creating an issue' do - page.within '.mr-widget-body' do - expect(page).to have_link 'open an issue to resolve them later', href: new_namespace_project_issue_path(project.namespace, project, merge_request_for_resolving_discussions: merge_request.iid) + context 'with the internal tracker disabled' do + before do + project.project_feature.update_attribute(:issues_access_level, ProjectFeature::DISABLED) + visit namespace_project_merge_request_path(project.namespace, project, merge_request) + end + + it 'does not show a link to create a new issue' do + expect(page).not_to have_link 'open an issue to resolve them later' end end - context 'creating an issue for discussions' do + context 'merge request has discussions that need to be resolved' do before do - page.click_link 'open an issue to resolve them later', href: new_namespace_project_issue_path(project.namespace, project, merge_request_for_resolving_discussions: merge_request.iid) + visit namespace_project_merge_request_path(project.namespace, project, merge_request) end - it 'has a hidden field for the discussion' do - merge_request_field = find('#merge_request_for_resolving_discussions', visible: false) + it 'shows a warning that the merge request contains unresolved discussions' do + expect(page).to have_content 'This merge request has unresolved discussions' + end - expect(merge_request_field.value).to eq(merge_request.iid.to_s) + it 'has a link to resolve all discussions by creating an issue' do + page.within '.mr-widget-body' do + expect(page).to have_link 'open an issue to resolve them later', href: new_namespace_project_issue_path(project.namespace, project, merge_request_for_resolving_discussions: merge_request.iid) + end end - it_behaves_like 'creating an issue for a discussion' + context 'creating an issue for discussions' do + before do + page.click_link 'open an issue to resolve them later', href: new_namespace_project_issue_path(project.namespace, project, merge_request_for_resolving_discussions: merge_request.iid) + end + + it_behaves_like 'creating an issue for a discussion' + end end end end -- cgit v1.2.1 From 6ffa988ac7cae8d9a93680aaee1481e4169d6b4a Mon Sep 17 00:00:00 2001 From: Bob Van Landuyt Date: Mon, 27 Feb 2017 17:42:32 +0100 Subject: Update copy for warning on new issue form --- app/views/shared/issuable/_form.html.haml | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index a10f9caf3c1..907eb399c61 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -53,16 +53,15 @@ = hidden_field_tag 'merge_request_for_resolving_discussions', @merge_request_for_resolving_discussions.iid - if @discussion_to_resolve = hidden_field_tag 'discussion_to_resolve', @discussion_to_resolve.id - Creating this issue will mark the discussion at - = link_to @discussion_to_resolve.noteable.to_reference, Gitlab::UrlBuilder.build(@discussion_to_resolve.first_note) + Creating this issue will resolve the discussion in + = link_to "#{@merge_request_for_resolving_discussions.to_reference} (discussion #{@discussion_to_resolve.first_note.id})", Gitlab::UrlBuilder.build(@discussion_to_resolve.first_note) - else - Creating this issue will mark all discussions in + Creating this issue will resolve all discussions in = link_to @merge_request_for_resolving_discussions.to_reference, merge_request_path(@merge_request_for_resolving_discussions) - as resolved. - else - You can't automatically mark all discussions in + The discussion at = link_to @merge_request_for_resolving_discussions.to_reference, merge_request_path(@merge_request_for_resolving_discussions) - as resolved. Ask someone with sufficient rights to resolve the them. + will stay unresolved. Ask someone with permission to resolve it. - is_footer = !(issuable.is_a?(MergeRequest) && issuable.new_record?) .row-content-block{ class: (is_footer ? "footer-block" : "middle-block") } -- cgit v1.2.1 From ffe135ccf650da3d2415447e4ce648880c1a241d Mon Sep 17 00:00:00 2001 From: Bob Van Landuyt Date: Mon, 27 Feb 2017 18:12:52 +0100 Subject: Move functionality for resolving discussions into a concern --- app/controllers/projects/issues_controller.rb | 5 +- app/services/issues/base_service.rb | 27 -------- app/services/issues/build_service.rb | 3 + app/services/issues/create_service.rb | 11 +-- app/services/issues/resolve_discussions.rb | 35 ++++++++++ spec/services/issues/base_service_spec.rb | 78 --------------------- spec/services/issues/resolve_discussions_spec.rb | 87 ++++++++++++++++++++++++ 7 files changed, 129 insertions(+), 117 deletions(-) create mode 100644 app/services/issues/resolve_discussions.rb delete mode 100644 spec/services/issues/base_service_spec.rb create mode 100644 spec/services/issues/resolve_discussions_spec.rb diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index d76643ff875..537506542b3 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -64,16 +64,15 @@ class Projects::IssuesController < Projects::ApplicationController params[:issue] ||= ActionController::Parameters.new( assignee_id: "" ) - build_params = issue_params.merge( merge_request_for_resolving_discussions: params[:merge_request_for_resolving_discussions], discussion_to_resolve: params[:discussion_to_resolve] ) service = Issues::BuildService.new(project, current_user, build_params) - @merge_request_for_resolving_discussions = service.merge_request_for_resolving_discussions - @discussion_to_resolve = service.discussions_to_resolve.first if params[:discussion_to_resolve] @issue = @noteable = service.execute + @merge_request_for_resolving_discussions = service.merge_request_for_resolving_discussions + @discussion_to_resolve = service.discussions_to_resolve.first if params[:discussion_to_resolve] respond_with(@issue) end diff --git a/app/services/issues/base_service.rb b/app/services/issues/base_service.rb index 058c398df02..ee1b40db718 100644 --- a/app/services/issues/base_service.rb +++ b/app/services/issues/base_service.rb @@ -1,13 +1,5 @@ module Issues class BaseService < ::IssuableBaseService - attr_reader :merge_request_for_resolving_discussions_iid, :discussion_to_resolve_id - def initialize(*args) - super - - @merge_request_for_resolving_discussions_iid ||= params.delete(:merge_request_for_resolving_discussions) - @discussion_to_resolve_id ||= params.delete(:discussion_to_resolve) - end - def hook_data(issue, action) issue_data = issue.to_hook_data(current_user) issue_url = Gitlab::UrlBuilder.build(issue) @@ -15,25 +7,6 @@ module Issues issue_data end - def merge_request_for_resolving_discussions - @merge_request_for_resolving_discussions ||= MergeRequestsFinder.new(current_user, project_id: project.id). - execute. - find_by(iid: merge_request_for_resolving_discussions_iid) - end - - def discussions_to_resolve - return [] unless merge_request_for_resolving_discussions - - @discussions_to_resolve ||= NotesFinder.new(project, current_user, { - discussion_id: discussion_to_resolve_id, - target_type: MergeRequest.name.underscore, - target_id: merge_request_for_resolving_discussions.id - }). - execute. - discussions. - select(&:to_be_resolved?) - end - private def execute_hooks(issue, action = 'open') diff --git a/app/services/issues/build_service.rb b/app/services/issues/build_service.rb index 1bcc5abd3f2..5f303b73d16 100644 --- a/app/services/issues/build_service.rb +++ b/app/services/issues/build_service.rb @@ -1,6 +1,9 @@ module Issues class BuildService < Issues::BaseService + include ResolveDiscussions + def execute + filter_resolve_discussion_params @issue = project.issues.new(issue_params) end diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb index 007c8354c8c..f40cb4a8fa0 100644 --- a/app/services/issues/create_service.rb +++ b/app/services/issues/create_service.rb @@ -1,9 +1,11 @@ module Issues class CreateService < Issues::BaseService include SpamCheckService + include ResolveDiscussions def execute filter_spam_check_params + filter_resolve_discussion_params issue_attributes = params.merge( merge_request_for_resolving_discussions: merge_request_for_resolving_discussions_iid, @@ -28,15 +30,6 @@ module Issues resolve_discussions_with_issue(issuable) end - def resolve_discussions_with_issue(issue) - return if discussions_to_resolve.empty? - - Discussions::ResolveService.new(project, current_user, - merge_request: merge_request_for_resolving_discussions, - follow_up_issue: issue). - execute(discussions_to_resolve) - end - private def user_agent_detail_service diff --git a/app/services/issues/resolve_discussions.rb b/app/services/issues/resolve_discussions.rb new file mode 100644 index 00000000000..62a89ae4c0b --- /dev/null +++ b/app/services/issues/resolve_discussions.rb @@ -0,0 +1,35 @@ +module Issues + module ResolveDiscussions + attr_reader :merge_request_for_resolving_discussions_iid, :discussion_to_resolve_id + + def filter_resolve_discussion_params + @merge_request_for_resolving_discussions_iid ||= params.delete(:merge_request_for_resolving_discussions) + @discussion_to_resolve_id ||= params.delete(:discussion_to_resolve) + end + + def resolve_discussions_with_issue(issue) + return if discussions_to_resolve.empty? + + Discussions::ResolveService.new(project, current_user, + merge_request: merge_request_for_resolving_discussions, + follow_up_issue: issue). + execute(discussions_to_resolve) + end + + def merge_request_for_resolving_discussions + @merge_request_for_resolving_discussions ||= MergeRequestsFinder.new(current_user, project_id: project.id). + execute. + find_by(iid: merge_request_for_resolving_discussions_iid) + end + + def discussions_to_resolve + return [] unless merge_request_for_resolving_discussions + + @discussions_to_resolve ||= NotesFinder.new(project, current_user, { + discussion_id: discussion_to_resolve_id, + target_type: MergeRequest.name.underscore, + target_id: merge_request_for_resolving_discussions.id + }).execute.discussions.select(&:to_be_resolved?) + end + end +end diff --git a/spec/services/issues/base_service_spec.rb b/spec/services/issues/base_service_spec.rb deleted file mode 100644 index 0cef09917ee..00000000000 --- a/spec/services/issues/base_service_spec.rb +++ /dev/null @@ -1,78 +0,0 @@ -require 'spec_helper.rb' - -describe Issues::BaseService, services: true do - let(:project) { create(:project) } - let(:user) { create(:user) } - - before do - project.team << [user, :developer] - end - - describe "for resolving discussions" do - let(:discussion) { Discussion.new([create(:diff_note_on_merge_request, project: project, note: "Almost done")]) } - let(:merge_request) { discussion.noteable } - let(:other_merge_request) { create(:merge_request, source_project: project, source_branch: "other") } - - describe "#discussions_to_resolve" do - it "contains a single discussion when matching merge request and discussion are passed" do - service = described_class.new( - project, - user, - discussion_to_resolve: discussion.id, - merge_request_for_resolving_discussions: merge_request.iid - ) - # We need to compare discussion id's because the Discussion-objects are rebuilt - # which causes the object-id's not to be different. - discussion_ids = service.discussions_to_resolve.map(&:id) - - expect(discussion_ids).to contain_exactly(discussion.id) - end - - it "contains all discussions when only a merge request is passed" do - second_discussion = Discussion.new([create(:diff_note_on_merge_request, - noteable: merge_request, - project: merge_request.target_project, - line_number: 15)]) - service = described_class.new( - project, - user, - merge_request_for_resolving_discussions: merge_request.iid - ) - # We need to compare discussion id's because the Discussion-objects are rebuilt - # which causes the object-id's not to be different. - discussion_ids = service.discussions_to_resolve.map(&:id) - - expect(discussion_ids).to contain_exactly(discussion.id, second_discussion.id) - end - - it "contains only unresolved discussions" do - second_discussion = Discussion.new([create(:diff_note_on_merge_request, :resolved, - noteable: merge_request, - project: merge_request.target_project, - line_number: 15, - )]) - service = described_class.new( - project, - user, - merge_request_for_resolving_discussions: merge_request.iid - ) - # We need to compare discussion id's because the Discussion-objects are rebuilt - # which causes the object-id's not to be different. - discussion_ids = service.discussions_to_resolve.map(&:id) - - expect(discussion_ids).to contain_exactly(discussion.id) - end - - it "is empty when a discussion and another merge request are passed" do - service = described_class.new( - project, - user, - discussion_to_resolve: discussion.id, - merge_request_for_resolving_discussions: other_merge_request.iid - ) - - expect(service.discussions_to_resolve).to be_empty - end - end - end -end diff --git a/spec/services/issues/resolve_discussions_spec.rb b/spec/services/issues/resolve_discussions_spec.rb new file mode 100644 index 00000000000..332927d10ad --- /dev/null +++ b/spec/services/issues/resolve_discussions_spec.rb @@ -0,0 +1,87 @@ +require 'spec_helper.rb' + +class DummyService < Issues::BaseService + include ::Issues::ResolveDiscussions + + def initialize(*args) + super + filter_resolve_discussion_params + end +end + +describe DummyService, services: true do + let(:project) { create(:project) } + let(:user) { create(:user) } + + before do + project.team << [user, :developer] + end + + describe "for resolving discussions" do + let(:discussion) { Discussion.new([create(:diff_note_on_merge_request, project: project, note: "Almost done")]) } + let(:merge_request) { discussion.noteable } + let(:other_merge_request) { create(:merge_request, source_project: project, source_branch: "other") } + + describe "#discussions_to_resolve" do + it "contains a single discussion when matching merge request and discussion are passed" do + service = described_class.new( + project, + user, + discussion_to_resolve: discussion.id, + merge_request_for_resolving_discussions: merge_request.iid + ) + # We need to compare discussion id's because the Discussion-objects are rebuilt + # which causes the object-id's not to be different. + discussion_ids = service.discussions_to_resolve.map(&:id) + + expect(discussion_ids).to contain_exactly(discussion.id) + end + + it "contains all discussions when only a merge request is passed" do + second_discussion = Discussion.new([create(:diff_note_on_merge_request, + noteable: merge_request, + project: merge_request.target_project, + line_number: 15)]) + service = described_class.new( + project, + user, + merge_request_for_resolving_discussions: merge_request.iid + ) + # We need to compare discussion id's because the Discussion-objects are rebuilt + # which causes the object-id's not to be different. + discussion_ids = service.discussions_to_resolve.map(&:id) + + expect(discussion_ids).to contain_exactly(discussion.id, second_discussion.id) + end + + it "contains only unresolved discussions" do + _second_discussion = Discussion.new([create(:diff_note_on_merge_request, :resolved, + noteable: merge_request, + project: merge_request.target_project, + line_number: 15, + )]) + service = described_class.new( + project, + user, + merge_request_for_resolving_discussions: merge_request.iid + ) + # We need to compare discussion id's because the Discussion-objects are rebuilt + # which causes the object-id's not to be different. + discussion_ids = service.discussions_to_resolve.map(&:id) + + expect(discussion_ids).to contain_exactly(discussion.id) + end + + it "is empty when a discussion and another merge request are passed" do + service = described_class.new( + project, + user, + discussion_to_resolve: discussion.id, + merge_request_for_resolving_discussions: other_merge_request.iid + ) + + expect(service.discussions_to_resolve).to be_empty + end + end + end +end -- cgit v1.2.1 From 40be92e74c60262b5ed92064fe42a27eac88f1c3 Mon Sep 17 00:00:00 2001 From: Bob Van Landuyt Date: Wed, 1 Mar 2017 10:15:58 +0100 Subject: Make API v3 compatible with the changes in `Issues::CreateService` --- lib/api/v3/issues.rb | 6 ------ 1 file changed, 6 deletions(-) diff --git a/lib/api/v3/issues.rb b/lib/api/v3/issues.rb index 5d7dfabfcd6..bb3535bedf8 100644 --- a/lib/api/v3/issues.rb +++ b/lib/api/v3/issues.rb @@ -140,12 +140,6 @@ module API issue_params = declared_params(include_missing: false) - if merge_request_iid = params[:merge_request_for_resolving_discussions] - issue_params[:merge_request_for_resolving_discussions] = MergeRequestsFinder.new(current_user, project_id: user_project.id). - execute. - find_by(iid: merge_request_iid) - end - issue = ::Issues::CreateService.new(user_project, current_user, issue_params.merge(request: request, api: true)).execute -- cgit v1.2.1 From f0dd2a54b31b46e2fa22a4041d902c7d9cc9c4dc Mon Sep 17 00:00:00 2001 From: Bob Van Landuyt Date: Thu, 2 Mar 2017 09:40:01 +0100 Subject: Link 'discussion' instead of date Since the date would be wrong for people in different timezones. --- app/services/issues/build_service.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/services/issues/build_service.rb b/app/services/issues/build_service.rb index 5f303b73d16..b2b72e84b75 100644 --- a/app/services/issues/build_service.rb +++ b/app/services/issues/build_service.rb @@ -37,10 +37,9 @@ module Issues def item_for_discussion(discussion) first_note = discussion.first_note_to_resolve || discussion.first_note other_note_count = discussion.notes.size - 1 - creation_time = first_note.created_at.to_s(:medium) note_url = Gitlab::UrlBuilder.build(first_note) - discussion_info = "- [ ] #{first_note.author.to_reference} commented in a discussion on [#{creation_time}](#{note_url}): " + discussion_info = "- [ ] #{first_note.author.to_reference} commented in a [discussion](#{note_url}): " discussion_info << " (+#{other_note_count} #{'comment'.pluralize(other_note_count)})" if other_note_count > 0 note_without_block_quotes = Banzai::Filter::BlockquoteFenceFilter.new(first_note.note).call -- cgit v1.2.1 From b9aa6c3583578d3ddd971139b91fb6226a49102a Mon Sep 17 00:00:00 2001 From: Bob Van Landuyt Date: Thu, 2 Mar 2017 10:13:30 +0100 Subject: Optimize finding `discussions_to_resolve` --- app/services/issues/resolve_discussions.rb | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/app/services/issues/resolve_discussions.rb b/app/services/issues/resolve_discussions.rb index 62a89ae4c0b..485838e66c4 100644 --- a/app/services/issues/resolve_discussions.rb +++ b/app/services/issues/resolve_discussions.rb @@ -25,11 +25,15 @@ module Issues def discussions_to_resolve return [] unless merge_request_for_resolving_discussions - @discussions_to_resolve ||= NotesFinder.new(project, current_user, { - discussion_id: discussion_to_resolve_id, - target_type: MergeRequest.name.underscore, - target_id: merge_request_for_resolving_discussions.id - }).execute.discussions.select(&:to_be_resolved?) + @discussions_to_resolve ||= begin + if discussion_to_resolve_id + Array(merge_request_for_resolving_discussions. + find_diff_discussion(discussion_to_resolve_id)) + else + merge_request_for_resolving_discussions + .resolvable_discussions + end + end end end end -- cgit v1.2.1 From dcec4c531f3268a7ae681bb7cc5d5b7217b4b33a Mon Sep 17 00:00:00 2001 From: Bob Van Landuyt Date: Tue, 7 Mar 2017 23:45:50 +0100 Subject: Finding discussions in NotesFinder is no longer required. Since the MergeRequest is now a required param for resolving discussions --- app/finders/notes_finder.rb | 6 ------ spec/finders/notes_finder_spec.rb | 11 ----------- 2 files changed, 17 deletions(-) diff --git a/app/finders/notes_finder.rb b/app/finders/notes_finder.rb index 98c66dfa021..6630c6384f2 100644 --- a/app/finders/notes_finder.rb +++ b/app/finders/notes_finder.rb @@ -11,7 +11,6 @@ class NotesFinder # target_type: string # target_id: integer # last_fetched_at: time - # discussion_id: string # search: string # def initialize(project, current_user, params = {}) @@ -23,7 +22,6 @@ class NotesFinder def execute @notes = since_fetch_at(@params[:last_fetched_at]) if @params[:last_fetched_at] - @notes = for_discussion(@params[:discussion_id]) if @params[:discussion_id] @notes end @@ -102,8 +100,4 @@ class NotesFinder @notes.where('updated_at > ?', last_fetched_at - FETCH_OVERLAP).fresh end - - def for_discussion(discussion_id) - @notes.where(discussion_id: discussion_id) - end end diff --git a/spec/finders/notes_finder_spec.rb b/spec/finders/notes_finder_spec.rb index 2c72f7839a6..77a04507be1 100644 --- a/spec/finders/notes_finder_spec.rb +++ b/spec/finders/notes_finder_spec.rb @@ -41,17 +41,6 @@ describe NotesFinder do expect(notes.count).to eq(0) end - context 'for a certain discussion' do - let!(:note_in_a_commit_discussion) { create(:note_on_commit, project: project) } - let!(:other_note) { create(:note_on_merge_request, project: project) } - - it 'finds the only the notes for a certain discussion id' do - notes = described_class.new(project, user, discussion_id: note_in_a_commit_discussion.discussion_id).execute - - expect(notes).to contain_exactly(note_in_a_commit_discussion) - end - end - context 'on restricted projects' do let(:project) do create(:empty_project, -- cgit v1.2.1 From 224c2addde2cd37daba354cd47a655891387dbe8 Mon Sep 17 00:00:00 2001 From: Bob Van Landuyt Date: Tue, 7 Mar 2017 23:49:16 +0100 Subject: No need to look up the notable separately in the ResolveService The merge request will now always be there when the discussion was resolved by creating a new issue. --- app/services/discussions/resolve_service.rb | 8 +------- spec/services/discussions/resolve_service_spec.rb | 2 +- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/app/services/discussions/resolve_service.rb b/app/services/discussions/resolve_service.rb index 4a803b47bba..0437195f588 100644 --- a/app/services/discussions/resolve_service.rb +++ b/app/services/discussions/resolve_service.rb @@ -9,13 +9,7 @@ module Discussions discussion.resolve!(current_user) - notify_discussion_resolved(discussion) - end - - def notify_discussion_resolved(discussion) - noteable = merge_request || discussion.noteable - - MergeRequests::ResolvedDiscussionNotificationService.new(project, current_user).execute(noteable) + MergeRequests::ResolvedDiscussionNotificationService.new(project, current_user).execute(merge_request) SystemNoteService.discussion_continued_in_issue(discussion, project, current_user, follow_up_issue) if follow_up_issue end diff --git a/spec/services/discussions/resolve_service_spec.rb b/spec/services/discussions/resolve_service_spec.rb index 5eb5b2b04d7..12c3cdf28c6 100644 --- a/spec/services/discussions/resolve_service_spec.rb +++ b/spec/services/discussions/resolve_service_spec.rb @@ -6,7 +6,7 @@ describe Discussions::ResolveService do let(:project) { merge_request.project } let(:merge_request) { discussion.noteable } let(:user) { create(:user) } - let(:service) { described_class.new(discussion.noteable.project, user) } + let(:service) { described_class.new(discussion.noteable.project, user, merge_request: merge_request) } before do project.team << [user, :master] -- cgit v1.2.1 From 52c0195dccb7057dab5ecce74d2095ddb80c1b53 Mon Sep 17 00:00:00 2001 From: Bob Van Landuyt Date: Wed, 8 Mar 2017 00:00:21 +0100 Subject: Better variable names So the title doesn't seem to be set _on_ a merge request. --- app/services/issues/build_service.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/services/issues/build_service.rb b/app/services/issues/build_service.rb index b2b72e84b75..540ed0f98c2 100644 --- a/app/services/issues/build_service.rb +++ b/app/services/issues/build_service.rb @@ -10,10 +10,10 @@ module Issues def issue_params_with_info_from_discussions return {} unless merge_request_for_resolving_discussions - { title: title_for_merge_request, description: description_for_discussions } + { title: title_from_merge_request, description: description_for_discussions } end - def title_for_merge_request + def title_from_merge_request "Follow-up from \"#{merge_request_for_resolving_discussions.title}\"" end -- cgit v1.2.1 From 5eee47758ccba75e2d1d7fa1c30b98a4c2820108 Mon Sep 17 00:00:00 2001 From: Bob Van Landuyt Date: Wed, 8 Mar 2017 00:55:50 +0100 Subject: Correctly indent quotes in new issue description By using `>`-quote style instead of `>>>`. Also: Update documentation images. --- app/services/issues/build_service.rb | 3 ++- .../img/preview_issue_for_discussion.png | Bin 176569 -> 214794 bytes .../img/preview_issue_for_discussions.png | Bin 178361 -> 143871 bytes spec/services/issues/build_service_spec.rb | 8 ++++---- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/app/services/issues/build_service.rb b/app/services/issues/build_service.rb index 540ed0f98c2..3d4b6b055c4 100644 --- a/app/services/issues/build_service.rb +++ b/app/services/issues/build_service.rb @@ -43,7 +43,8 @@ module Issues discussion_info << " (+#{other_note_count} #{'comment'.pluralize(other_note_count)})" if other_note_count > 0 note_without_block_quotes = Banzai::Filter::BlockquoteFenceFilter.new(first_note.note).call - quote = ">>>\n#{note_without_block_quotes}\n>>>" + spaces = ' ' * 4 + quote = note_without_block_quotes.lines.map { |line| "#{spaces}> #{line}" }.join [discussion_info, quote].join("\n\n") end diff --git a/doc/user/project/merge_requests/img/preview_issue_for_discussion.png b/doc/user/project/merge_requests/img/preview_issue_for_discussion.png index 798365aa4db..b38cf7d5c66 100644 Binary files a/doc/user/project/merge_requests/img/preview_issue_for_discussion.png and b/doc/user/project/merge_requests/img/preview_issue_for_discussion.png differ diff --git a/doc/user/project/merge_requests/img/preview_issue_for_discussions.png b/doc/user/project/merge_requests/img/preview_issue_for_discussions.png index 9fdd387676c..3fe0a666678 100644 Binary files a/doc/user/project/merge_requests/img/preview_issue_for_discussions.png and b/doc/user/project/merge_requests/img/preview_issue_for_discussions.png differ diff --git a/spec/services/issues/build_service_spec.rb b/spec/services/issues/build_service_spec.rb index 76417279923..79b093e51fb 100644 --- a/spec/services/issues/build_service_spec.rb +++ b/spec/services/issues/build_service_spec.rb @@ -57,11 +57,11 @@ describe Issues::BuildService, services: true do "with a blockquote\n"\ "> That has a quote\n"\ ">>>\n" - note_result = "This is a string\n"\ - "> with a blockquote\n"\ - "> > That has a quote\n" + note_result = " > This is a string\n"\ + " > > with a blockquote\n"\ + " > > > That has a quote\n" discussion = Discussion.new([create(:diff_note_on_merge_request, note: note_text)]) - expect(service.item_for_discussion(discussion)).to include(">>>\n#{note_result}\n>>>") + expect(service.item_for_discussion(discussion)).to include(note_result) end end -- cgit v1.2.1 From 843092b631ec667aff3cb3617c40a6eef811c097 Mon Sep 17 00:00:00 2001 From: Bob Van Landuyt Date: Wed, 8 Mar 2017 01:20:05 +0100 Subject: Don't filter params and merge them back in later By building the issue before filtering --- app/services/issues/create_service.rb | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb index f40cb4a8fa0..aba590494d6 100644 --- a/app/services/issues/create_service.rb +++ b/app/services/issues/create_service.rb @@ -4,16 +4,11 @@ module Issues include ResolveDiscussions def execute + @issue = BuildService.new(project, current_user, params).execute + filter_spam_check_params filter_resolve_discussion_params - issue_attributes = params.merge( - merge_request_for_resolving_discussions: merge_request_for_resolving_discussions_iid, - discussion_to_resolve: discussion_to_resolve_id - ) - - @issue = BuildService.new(project, current_user, issue_attributes).execute - create(@issue) end -- cgit v1.2.1 From 3eeea426387e6959c576adde1978a12bfff59105 Mon Sep 17 00:00:00 2001 From: Bob Van Landuyt Date: Wed, 8 Mar 2017 01:30:43 +0100 Subject: Move `resolve_discussions_with_issue` to `Issues::CreateService` --- app/services/issues/create_service.rb | 9 +++++++++ app/services/issues/resolve_discussions.rb | 9 --------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb index aba590494d6..175312aed91 100644 --- a/app/services/issues/create_service.rb +++ b/app/services/issues/create_service.rb @@ -25,6 +25,15 @@ module Issues resolve_discussions_with_issue(issuable) end + def resolve_discussions_with_issue(issue) + return if discussions_to_resolve.empty? + + Discussions::ResolveService.new(project, current_user, + merge_request: merge_request_for_resolving_discussions, + follow_up_issue: issue). + execute(discussions_to_resolve) + end + private def user_agent_detail_service diff --git a/app/services/issues/resolve_discussions.rb b/app/services/issues/resolve_discussions.rb index 485838e66c4..b7daa3f2916 100644 --- a/app/services/issues/resolve_discussions.rb +++ b/app/services/issues/resolve_discussions.rb @@ -7,15 +7,6 @@ module Issues @discussion_to_resolve_id ||= params.delete(:discussion_to_resolve) end - def resolve_discussions_with_issue(issue) - return if discussions_to_resolve.empty? - - Discussions::ResolveService.new(project, current_user, - merge_request: merge_request_for_resolving_discussions, - follow_up_issue: issue). - execute(discussions_to_resolve) - end - def merge_request_for_resolving_discussions @merge_request_for_resolving_discussions ||= MergeRequestsFinder.new(current_user, project_id: project.id). execute. -- cgit v1.2.1 From 43da8db7ed63e40a32dce0a13347855d6f432d93 Mon Sep 17 00:00:00 2001 From: Bob Van Landuyt Date: Wed, 8 Mar 2017 01:39:11 +0100 Subject: Clean up `discussions_to_resolve`-method --- app/services/issues/resolve_discussions.rb | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/app/services/issues/resolve_discussions.rb b/app/services/issues/resolve_discussions.rb index b7daa3f2916..47fd334a9b2 100644 --- a/app/services/issues/resolve_discussions.rb +++ b/app/services/issues/resolve_discussions.rb @@ -16,15 +16,15 @@ module Issues def discussions_to_resolve return [] unless merge_request_for_resolving_discussions - @discussions_to_resolve ||= begin - if discussion_to_resolve_id - Array(merge_request_for_resolving_discussions. - find_diff_discussion(discussion_to_resolve_id)) - else - merge_request_for_resolving_discussions - .resolvable_discussions - end - end + @discussions_to_resolve ||= + if discussion_to_resolve_id + discussion_or_nil = merge_request_for_resolving_discussions + .find_diff_discussion(discussion_to_resolve_id) + Array(discussion_or_nil) + else + merge_request_for_resolving_discussions + .resolvable_discussions + end end end end -- cgit v1.2.1 From 9709309e738aa1703afd78d2c5482b367a662d4a Mon Sep 17 00:00:00 2001 From: Bob Van Landuyt Date: Wed, 8 Mar 2017 01:43:48 +0100 Subject: Update copy --- app/views/discussions/_new_issue_for_all_discussions.html.haml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/discussions/_new_issue_for_all_discussions.html.haml b/app/views/discussions/_new_issue_for_all_discussions.html.haml index 21fbbbee54b..01c5a7b4e54 100644 --- a/app/views/discussions/_new_issue_for_all_discussions.html.haml +++ b/app/views/discussions/_new_issue_for_all_discussions.html.haml @@ -1,6 +1,6 @@ - if merge_request.discussions_can_be_resolved_by?(current_user) && can?(current_user, :create_issue, @project) .btn-group{ role: "group", "v-if" => "unresolvedDiscussionCount > 0" } .btn.btn-default.discussion-create-issue-btn.has-tooltip{ title: "Resolve all discussions in new issue", - "aria-label" => "Resolve all discussions in new issue", - "data-container" => "body" } + "aria-label" => "Resolve all discussions in a new issue", + "data-container" => "body" } = link_to custom_icon('icon_mr_issue'), new_namespace_project_issue_path(@project.namespace, @project, merge_request_for_resolving_discussions: merge_request.iid), title: "Resolve all discussions in new issue", class: 'new-issue-for-discussion' -- cgit v1.2.1 From f82115885d07e4eefec3594bdfc4bcd0210a1277 Mon Sep 17 00:00:00 2001 From: Bob Van Landuyt Date: Wed, 8 Mar 2017 16:39:20 +0100 Subject: Build link to discussions to resolve in a helper --- app/helpers/issues_helper.rb | 14 ++++++++++++++ app/views/shared/issuable/_form.html.haml | 5 ++--- spec/helpers/issues_helper_spec.rb | 32 +++++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb index 4bdf07fe1ad..63b6fde60c3 100644 --- a/app/helpers/issues_helper.rb +++ b/app/helpers/issues_helper.rb @@ -134,6 +134,20 @@ module IssuesHelper options_from_collection_for_select(options, 'name', 'title', params[:due_date]) end + def link_to_discussions_to_resolve(merge_request, single_discussion) + link_text = merge_request.to_reference + link_text += " (discussion #{single_discussion.first_note.id})" if single_discussion + + path = if single_discussion + Gitlab::UrlBuilder.build(single_discussion.first_note) + else + project = merge_request.project + namespace_project_merge_request_path(project.namespace, project, merge_request) + end + + link_to link_text, path + end + # Required for Banzai::Filter::IssueReferenceFilter module_function :url_for_issue end diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index 907eb399c61..50b4bcff3bd 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -54,13 +54,12 @@ - if @discussion_to_resolve = hidden_field_tag 'discussion_to_resolve', @discussion_to_resolve.id Creating this issue will resolve the discussion in - = link_to "#{@merge_request_for_resolving_discussions.to_reference} (discussion #{@discussion_to_resolve.first_note.id})", Gitlab::UrlBuilder.build(@discussion_to_resolve.first_note) - else Creating this issue will resolve all discussions in - = link_to @merge_request_for_resolving_discussions.to_reference, merge_request_path(@merge_request_for_resolving_discussions) + = link_to_discussions_to_resolve(@merge_request_for_resolving_discussions, @discussion_to_resolve) - else The discussion at - = link_to @merge_request_for_resolving_discussions.to_reference, merge_request_path(@merge_request_for_resolving_discussions) + = link_to_discussions_to_resolve(@merge_request_for_resolving_discussions, @discussion_to_resolve) will stay unresolved. Ask someone with permission to resolve it. - is_footer = !(issuable.is_a?(MergeRequest) && issuable.new_record?) diff --git a/spec/helpers/issues_helper_spec.rb b/spec/helpers/issues_helper_spec.rb index 88d853935c7..f0554cc068d 100644 --- a/spec/helpers/issues_helper_spec.rb +++ b/spec/helpers/issues_helper_spec.rb @@ -131,4 +131,36 @@ describe IssuesHelper do expect(options).to have_selector('option', text: milestone2.title) end end + + describe "#link_to_discussions_to_resolve" do + describe "passing only a merge request" do + let(:merge_request) { create(:merge_request) } + + it "links just the merge request" do + expected_path = namespace_project_merge_request_path(merge_request.project.namespace, merge_request.project, merge_request) + + expect(link_to_discussions_to_resolve(merge_request, nil)).to include(expected_path) + end + + it "containst the reference to the merge request" do + expect(link_to_discussions_to_resolve(merge_request, nil)).to include(merge_request.to_reference) + end + end + + describe "when passing a discussion" do + let(:diff_note) { create(:diff_note_on_merge_request) } + let(:merge_request) { diff_note.noteable } + let(:discussion) { Discussion.new([diff_note]) } + + it "links to the merge request with first note if a single discussion was passed" do + expected_path = Gitlab::UrlBuilder.build(diff_note) + + expect(link_to_discussions_to_resolve(merge_request, discussion)).to include(expected_path) + end + + it "contains both the reference to the merge request and a mention of the discussion" do + expect(link_to_discussions_to_resolve(merge_request, discussion)).to include("#{merge_request.to_reference} (discussion #{diff_note.id})") + end + end + end end -- cgit v1.2.1 From 6f9304e03078acfa9c7afdceb1ed02e4c87265ca Mon Sep 17 00:00:00 2001 From: Bob Van Landuyt Date: Wed, 8 Mar 2017 20:16:17 +0100 Subject: Add a flash messages when an issue resolving discussions is created --- app/controllers/projects/issues_controller.rb | 8 +++++++- spec/controllers/projects/issues_controller_spec.rb | 14 ++++++++++++++ .../resolving_discussions_in_issues_shared_examples.rb | 10 ++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 537506542b3..ed31774bc70 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -106,7 +106,13 @@ class Projects::IssuesController < Projects::ApplicationController discussion_to_resolve: params[:discussion_to_resolve] ) - @issue = Issues::CreateService.new(project, current_user, create_params).execute + service = Issues::CreateService.new(project, current_user, create_params) + @issue = service.execute + + if service.discussions_to_resolve.any? && service.discussions_to_resolve.all?(&:resolved?) + resolve_count = service.discussions_to_resolve.size + flash[:notice] = "Resolved #{resolve_count} #{'discussion'.pluralize(resolve_count)}." + end respond_to do |format| format.html do diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb index 46e2f4cef29..70b1ae237ce 100644 --- a/spec/controllers/projects/issues_controller_spec.rb +++ b/spec/controllers/projects/issues_controller_spec.rb @@ -495,6 +495,20 @@ describe Projects::IssuesController do expect(discussion.resolved?).to eq(true) end + it 'sets a flash message' do + post_issue(title: 'Hello') + + expect(flash[:notice]).to eq('Resolved 1 discussion.') + end + + it 'pluralizes the flash message when resolving multiple discussions' do + create(:diff_note_on_merge_request, noteable: merge_request, project: project, line_number: 15) + + post_issue(title: 'Hello') + + expect(flash[:notice]).to eq('Resolved 2 discussions.') + end + it "resolves a single discussion" do post_issue(other_params: { discussion_to_resolve: discussion.id }) discussion.first_note.reload diff --git a/spec/support/features/resolving_discussions_in_issues_shared_examples.rb b/spec/support/features/resolving_discussions_in_issues_shared_examples.rb index 669b306d94e..c8e5021d9a6 100644 --- a/spec/support/features/resolving_discussions_in_issues_shared_examples.rb +++ b/spec/support/features/resolving_discussions_in_issues_shared_examples.rb @@ -23,6 +23,16 @@ shared_examples 'creating an issue for a discussion' do expect(discussion.resolved?).to eq(true) end + it 'shows a flash messaage after resolving a discussion' do + click_button 'Submit issue' + + page.within '.flash-notice' do + # Only check for the word 'Resolved' since the spec might have resolved + # multiple discussions + expect(page).to have_content('Resolved') + end + end + it 'has a hidden field for the merge request' do merge_request_field = find('#merge_request_for_resolving_discussions', visible: false) -- cgit v1.2.1 From 5681104925be658aad97ea37896428d9618f772f Mon Sep 17 00:00:00 2001 From: Bob Van Landuyt Date: Wed, 8 Mar 2017 21:03:52 +0100 Subject: Fit 'unresolved discussion widget together --- app/assets/stylesheets/pages/notes.scss | 4 +++- app/views/projects/merge_requests/_show.html.haml | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 71005a4666e..e27a5c0e7b6 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -521,7 +521,9 @@ ul.notes { } .btn.discussion-create-issue-btn { - margin-left: 4px; + margin-left: -4px; + border-radius: 0; + border-right: 0; a { padding: 0; diff --git a/app/views/projects/merge_requests/_show.html.haml b/app/views/projects/merge_requests/_show.html.haml index 524cec5e1f9..c8f097c69da 100644 --- a/app/views/projects/merge_requests/_show.html.haml +++ b/app/views/projects/merge_requests/_show.html.haml @@ -82,8 +82,8 @@ = render "shared/icons/icon_status_success.svg" %span.line-resolve-text {{ resolvedDiscussionCount }}/{{ discussionCount }} {{ resolvedCountText }} resolved - = render "discussions/jump_to_next" = 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 -- cgit v1.2.1 From 67bd277ae6e7246543b2aeaf83a9c39412e18d05 Mon Sep 17 00:00:00 2001 From: Bob Van Landuyt Date: Thu, 9 Mar 2017 11:38:53 +0100 Subject: Make sure the for a merge request only gets executed once. --- app/services/issues/resolve_discussions.rb | 4 +++- spec/services/issues/resolve_discussions_spec.rb | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/app/services/issues/resolve_discussions.rb b/app/services/issues/resolve_discussions.rb index 47fd334a9b2..89105dceb5f 100644 --- a/app/services/issues/resolve_discussions.rb +++ b/app/services/issues/resolve_discussions.rb @@ -8,7 +8,9 @@ module Issues end def merge_request_for_resolving_discussions - @merge_request_for_resolving_discussions ||= MergeRequestsFinder.new(current_user, project_id: project.id). + return @merge_request_for_resolving_discussions if defined?(@merge_request_for_resolving_discussions) + + @merge_request_for_resolving_discussions = MergeRequestsFinder.new(current_user, project_id: project.id). execute. find_by(iid: merge_request_for_resolving_discussions_iid) end diff --git a/spec/services/issues/resolve_discussions_spec.rb b/spec/services/issues/resolve_discussions_spec.rb index 332927d10ad..a7175398aa1 100644 --- a/spec/services/issues/resolve_discussions_spec.rb +++ b/spec/services/issues/resolve_discussions_spec.rb @@ -22,6 +22,25 @@ describe DummyService, services: true do let(:merge_request) { discussion.noteable } let(:other_merge_request) { create(:merge_request, source_project: project, source_branch: "other") } + describe "#merge_request_for_resolving_discussion" do + let(:service) { described_class.new(project, user, merge_request_for_resolving_discussions: merge_request.iid) } + + it "finds the merge request" do + expect(service.merge_request_for_resolving_discussions).to eq(merge_request) + end + + it "only queries for the merge request once" do + fake_finder = double + fake_results = double + + expect(fake_finder).to receive(:execute).and_return(fake_results).exactly(1) + expect(fake_results).to receive(:find_by).exactly(1) + expect(MergeRequestsFinder).to receive(:new).and_return(fake_finder).exactly(1) + + 2.times { service.merge_request_for_resolving_discussions } + end + end + describe "#discussions_to_resolve" do it "contains a single discussion when matching merge request and discussion are passed" do service = described_class.new( -- cgit v1.2.1 From 40dd993774814c484d3161c86287dd56cf06e436 Mon Sep 17 00:00:00 2001 From: Bob Van Landuyt Date: Thu, 9 Mar 2017 12:20:09 +0100 Subject: Replace the warning with an info icon --- app/views/shared/issuable/_form.html.haml | 2 +- .../img/preview_issue_for_discussion.png | Bin 214794 -> 82412 bytes .../img/resolve_discussion_issue_notice.png | Bin 11123 -> 10307 bytes 3 files changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index 50b4bcff3bd..9d0c165450b 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -48,7 +48,7 @@ - if @merge_request_for_resolving_discussions .form-group .col-sm-10.col-sm-offset-2 - = icon('exclamation-triangle') + = icon('info-circle') - if @merge_request_for_resolving_discussions.discussions_can_be_resolved_by?(current_user) = hidden_field_tag 'merge_request_for_resolving_discussions', @merge_request_for_resolving_discussions.iid - if @discussion_to_resolve diff --git a/doc/user/project/merge_requests/img/preview_issue_for_discussion.png b/doc/user/project/merge_requests/img/preview_issue_for_discussion.png index b38cf7d5c66..2ee0653b2ba 100644 Binary files a/doc/user/project/merge_requests/img/preview_issue_for_discussion.png and b/doc/user/project/merge_requests/img/preview_issue_for_discussion.png differ diff --git a/doc/user/project/merge_requests/img/resolve_discussion_issue_notice.png b/doc/user/project/merge_requests/img/resolve_discussion_issue_notice.png index 8c7ce215ae0..e0ee6a39ffd 100644 Binary files a/doc/user/project/merge_requests/img/resolve_discussion_issue_notice.png and b/doc/user/project/merge_requests/img/resolve_discussion_issue_notice.png differ -- cgit v1.2.1 From ab520862903749702bceeff3c4d67b90c969e65d Mon Sep 17 00:00:00 2001 From: Bob Van Landuyt Date: Thu, 9 Mar 2017 13:48:07 +0100 Subject: Show correct message when user cannot resolve discussions --- app/views/shared/issuable/_form.html.haml | 7 +- ..._issue_for_discussions_in_merge_request_spec.rb | 102 ++++++++++++--------- ...issue_for_single_discussion_in_merge_request.rb | 84 ++++++++++------- 3 files changed, 114 insertions(+), 79 deletions(-) diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index 9d0c165450b..9e7b48ca79c 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -58,9 +58,12 @@ Creating this issue will resolve all discussions in = link_to_discussions_to_resolve(@merge_request_for_resolving_discussions, @discussion_to_resolve) - else - The discussion at + The + = @discussion_to_resolve ? 'discussion' : 'discussions' + at = link_to_discussions_to_resolve(@merge_request_for_resolving_discussions, @discussion_to_resolve) - will stay unresolved. Ask someone with permission to resolve it. + will stay unresolved. Ask someone with permission to resolve + = @discussion_to_resolve ? 'it.' : 'them.' - is_footer = !(issuable.is_a?(MergeRequest) && issuable.new_record?) .row-content-block{ class: (is_footer ? "footer-block" : "middle-block") } diff --git a/spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb b/spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb index 4751b16013d..6372432aa0d 100644 --- a/spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb +++ b/spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb @@ -6,74 +6,88 @@ feature 'Resolving all open discussions in a merge request from an issue', featu let(:merge_request) { create(:merge_request, source_project: project) } let!(:discussion) { Discussion.for_diff_notes([create(:diff_note_on_merge_request, noteable: merge_request, project: project)]).first } - before do - project.team << [user, :master] - login_as user - visit namespace_project_merge_request_path(project.namespace, project, merge_request) - end - - it 'shows a button to resolve all discussions by creating a new issue' do - within('li#resolve-count-app') do - expect(page).to have_link "Resolve all discussions in new issue", href: new_namespace_project_issue_path(project.namespace, project, merge_request_for_resolving_discussions: merge_request.iid) - end - end - - context 'resolving the discussion' do + describe 'as a user with access to the project' do before do - click_button 'Resolve discussion' - end - - it 'hides the link for creating a new issue' do - expect(page).not_to have_link "Resolve all discussions in new issue", href: new_namespace_project_issue_path(project.namespace, project, merge_request_for_resolving_discussions: merge_request.iid) + project.team << [user, :master] + login_as user + visit namespace_project_merge_request_path(project.namespace, project, merge_request) end - end - context 'creating an issue for discussions' do - before do - click_link "Resolve all discussions in new issue", href: new_namespace_project_issue_path(project.namespace, project, merge_request_for_resolving_discussions: merge_request.iid) + it 'shows a button to resolve all discussions by creating a new issue' do + within('li#resolve-count-app') do + expect(page).to have_link "Resolve all discussions in new issue", href: new_namespace_project_issue_path(project.namespace, project, merge_request_for_resolving_discussions: merge_request.iid) + end end - it_behaves_like 'creating an issue for a discussion' - end + context 'resolving the discussion' do + before do + click_button 'Resolve discussion' + end - context 'for a project where all discussions need to be resolved before merging' do - before do - project.update_attribute(:only_allow_merge_if_all_discussions_are_resolved, true) + it 'hides the link for creating a new issue' do + expect(page).not_to have_link "Resolve all discussions in new issue", href: new_namespace_project_issue_path(project.namespace, project, merge_request_for_resolving_discussions: merge_request.iid) + end end - context 'with the internal tracker disabled' do + context 'creating an issue for discussions' do before do - project.project_feature.update_attribute(:issues_access_level, ProjectFeature::DISABLED) - visit namespace_project_merge_request_path(project.namespace, project, merge_request) + click_link "Resolve all discussions in new issue", href: new_namespace_project_issue_path(project.namespace, project, merge_request_for_resolving_discussions: merge_request.iid) end - it 'does not show a link to create a new issue' do - expect(page).not_to have_link 'open an issue to resolve them later' - end + it_behaves_like 'creating an issue for a discussion' end - context 'merge request has discussions that need to be resolved' do + context 'for a project where all discussions need to be resolved before merging' do before do - visit namespace_project_merge_request_path(project.namespace, project, merge_request) + project.update_attribute(:only_allow_merge_if_all_discussions_are_resolved, true) end - it 'shows a warning that the merge request contains unresolved discussions' do - expect(page).to have_content 'This merge request has unresolved discussions' - end + context 'with the internal tracker disabled' do + before do + project.project_feature.update_attribute(:issues_access_level, ProjectFeature::DISABLED) + visit namespace_project_merge_request_path(project.namespace, project, merge_request) + end - it 'has a link to resolve all discussions by creating an issue' do - page.within '.mr-widget-body' do - expect(page).to have_link 'open an issue to resolve them later', href: new_namespace_project_issue_path(project.namespace, project, merge_request_for_resolving_discussions: merge_request.iid) + it 'does not show a link to create a new issue' do + expect(page).not_to have_link 'open an issue to resolve them later' end end - context 'creating an issue for discussions' do + context 'merge request has discussions that need to be resolved' do before do - page.click_link 'open an issue to resolve them later', href: new_namespace_project_issue_path(project.namespace, project, merge_request_for_resolving_discussions: merge_request.iid) + visit namespace_project_merge_request_path(project.namespace, project, merge_request) + end + + it 'shows a warning that the merge request contains unresolved discussions' do + expect(page).to have_content 'This merge request has unresolved discussions' + end + + it 'has a link to resolve all discussions by creating an issue' do + page.within '.mr-widget-body' do + expect(page).to have_link 'open an issue to resolve them later', href: new_namespace_project_issue_path(project.namespace, project, merge_request_for_resolving_discussions: merge_request.iid) + end end - it_behaves_like 'creating an issue for a discussion' + context 'creating an issue for discussions' do + before do + page.click_link 'open an issue to resolve them later', href: new_namespace_project_issue_path(project.namespace, project, merge_request_for_resolving_discussions: merge_request.iid) + end + + it_behaves_like 'creating an issue for a discussion' + end end end end + + describe 'as a reporter' do + before do + project.team << [user, :reporter] + login_as user + visit new_namespace_project_issue_path(project.namespace, project, merge_request_for_resolving_discussions: merge_request.iid) + end + + it 'Shows a notice to ask someone else to resolve the discussions' do + expect(page).to have_content("The discussions at #{merge_request.to_reference} will stay unresolved. Ask someone with permission to resolve them.") + end + end end diff --git a/spec/features/issues/create_issue_for_single_discussion_in_merge_request.rb b/spec/features/issues/create_issue_for_single_discussion_in_merge_request.rb index 61b5b226c03..6d618505fde 100644 --- a/spec/features/issues/create_issue_for_single_discussion_in_merge_request.rb +++ b/spec/features/issues/create_issue_for_single_discussion_in_merge_request.rb @@ -6,58 +6,76 @@ feature 'Resolve an open discussion in a merge request by creating an issue', fe let(:merge_request) { create(:merge_request, source_project: project) } let!(:discussion) { Discussion.for_diff_notes([create(:diff_note_on_merge_request, noteable: merge_request, project: project)]).first } - before do - project.team << [user, :master] - login_as user - visit namespace_project_merge_request_path(project.namespace, project, merge_request) - end - - context 'with the internal tracker disabled' do + describe 'As a user with access to the project' do before do - project.project_feature.update_attribute(:issues_access_level, ProjectFeature::DISABLED) + project.team << [user, :master] + login_as user visit namespace_project_merge_request_path(project.namespace, project, merge_request) end - it 'does not show a link to create a new issue' do - expect(page).not_to have_link 'Resolve this discussion in a new issue' + context 'with the internal tracker disabled' do + before do + project.project_feature.update_attribute(:issues_access_level, ProjectFeature::DISABLED) + visit namespace_project_merge_request_path(project.namespace, project, merge_request) + end + + it 'does not show a link to create a new issue' do + expect(page).not_to have_link 'Resolve this discussion in a new issue' + end end - end - context 'resolving the discussion', js: true do - before do - click_button 'Resolve discussion' + context 'resolving the discussion', js: true do + before do + click_button 'Resolve discussion' + end + + it 'hides the link for creating a new issue' do + expect(page).not_to have_link 'Resolve this discussion in a new issue' + end + + it 'shows the link for creating a new issue when unresolving a discussion' do + page.within '.diff-content' do + click_button 'Unresolve discussion' + end + + expect(page).to have_link 'Resolve this discussion in a new issue' + end end - it 'hides the link for creating a new issue' do - expect(page).not_to have_link 'Resolve this discussion in a new issue' + it 'has a link to create a new issue for a discussion' do + new_issue_link = new_namespace_project_issue_path(project.namespace, project, discussion_to_resolve: discussion.id, merge_request_for_resolving_discussions: merge_request.iid) + + expect(page).to have_link 'Resolve this discussion in a new issue', href: new_issue_link end - it 'shows the link for creating a new issue when unresolving a discussion' do - page.within '.diff-content' do - click_button 'Unresolve discussion' + context 'creating the issue' do + before do + click_link 'Resolve this discussion in a new issue', href: new_namespace_project_issue_path(project.namespace, project, discussion_to_resolve: discussion.id, merge_request_for_resolving_discussions: merge_request.iid) end - expect(page).to have_link 'Resolve this discussion in a new issue' - end - end + it 'has a hidden field for the discussion' do + discussion_field = find('#discussion_to_resolve', visible: false) - it 'has a link to create a new issue for a discussion' do - new_issue_link = new_namespace_project_issue_path(project.namespace, project, discussion_to_resolve: discussion.id, merge_request_for_resolving_discussions: merge_request.iid) + expect(discussion_field.value).to eq(discussion.id.to_s) + end - expect(page).to have_link 'Resolve this discussion in a new issue', href: new_issue_link + it_behaves_like 'creating an issue for a discussion' + end end - context 'creating the issue' do + describe 'as a reporter' do before do - click_link 'Resolve this discussion in a new issue', href: new_namespace_project_issue_path(project.namespace, project, discussion_to_resolve: discussion.id, merge_request_for_resolving_discussions: merge_request.iid) + project.team << [user, :reporter] + login_as user + visit new_namespace_project_issue_path(project.namespace, project, + merge_request_for_resolving_discussions: merge_request.iid, + discussion_to_resolve: discussion.id) end - it 'has a hidden field for the discussion' do - discussion_field = find('#discussion_to_resolve', visible: false) - - expect(discussion_field.value).to eq(discussion.id.to_s) + it 'Shows a notice to ask someone else to resolve the discussions' do + expect(page).to have_content("The discussion at #{merge_request.to_reference}"\ + "(discussion #{discussion.first_note.id}) will stay unresolved."\ + "Ask someone with permission to resolve it.") end - - it_behaves_like 'creating an issue for a discussion' end end -- cgit v1.2.1 From f8b5fef9f7afadb3f63575e05c68d636f809a1cb Mon Sep 17 00:00:00 2001 From: Bob Van Landuyt Date: Thu, 9 Mar 2017 14:25:54 +0100 Subject: Separate flash messages for resolving one or multiple discussions --- app/controllers/projects/issues_controller.rb | 9 +++++--- .../controllers/projects/issues_controller_spec.rb | 24 +++++++++++----------- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index ed31774bc70..35d1a52b251 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -109,9 +109,12 @@ class Projects::IssuesController < Projects::ApplicationController service = Issues::CreateService.new(project, current_user, create_params) @issue = service.execute - if service.discussions_to_resolve.any? && service.discussions_to_resolve.all?(&:resolved?) - resolve_count = service.discussions_to_resolve.size - flash[:notice] = "Resolved #{resolve_count} #{'discussion'.pluralize(resolve_count)}." + if service.discussions_to_resolve.count(&:resolved?) > 0 + flash[:notice] = if params[:discussion_to_resolve].present? + "Resolved 1 discussion." + else + "Resolved all discussions." + end end respond_to do |format| diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb index 70b1ae237ce..5e70966aa2d 100644 --- a/spec/controllers/projects/issues_controller_spec.rb +++ b/spec/controllers/projects/issues_controller_spec.rb @@ -498,22 +498,22 @@ describe Projects::IssuesController do it 'sets a flash message' do post_issue(title: 'Hello') - expect(flash[:notice]).to eq('Resolved 1 discussion.') + expect(flash[:notice]).to eq('Resolved all discussions.') end - it 'pluralizes the flash message when resolving multiple discussions' do - create(:diff_note_on_merge_request, noteable: merge_request, project: project, line_number: 15) - - post_issue(title: 'Hello') - - expect(flash[:notice]).to eq('Resolved 2 discussions.') - end + describe "resolving a single discussion" do + before do + post_issue({ title: 'Hello' }, other_params: { discussion_to_resolve: discussion.id }) + end + it 'resolves a single discussion' do + discussion.first_note.reload - it "resolves a single discussion" do - post_issue(other_params: { discussion_to_resolve: discussion.id }) - discussion.first_note.reload + expect(discussion.resolved?).to eq(true) + end - expect(discussion.resolved?).to eq(true) + it 'sets a flash message that one discussion was resolved' do + expect(flash[:notice]).to eq('Resolved 1 discussion.') + end end end -- cgit v1.2.1 From 80b67a79f45bd728d8eb2980d08004f0d2306a91 Mon Sep 17 00:00:00 2001 From: Bob Van Landuyt Date: Thu, 9 Mar 2017 14:28:57 +0100 Subject: Indicate that `single_discussion` is optional --- app/helpers/issues_helper.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb index 63b6fde60c3..6978b0c89fd 100644 --- a/app/helpers/issues_helper.rb +++ b/app/helpers/issues_helper.rb @@ -134,7 +134,7 @@ module IssuesHelper options_from_collection_for_select(options, 'name', 'title', params[:due_date]) end - def link_to_discussions_to_resolve(merge_request, single_discussion) + def link_to_discussions_to_resolve(merge_request, single_discussion = nil) link_text = merge_request.to_reference link_text += " (discussion #{single_discussion.first_note.id})" if single_discussion -- cgit v1.2.1 From 091b846ebebc5908b8f09b5fb1319546f7184ec4 Mon Sep 17 00:00:00 2001 From: Bob Van Landuyt Date: Thu, 9 Mar 2017 18:27:51 +0100 Subject: Renamed `.js.es6` to `.js` --- .../components/new_issue_for_discussion.js | 29 ++++++++++++++++++++++ .../components/new_issue_for_discussion.js.es6 | 29 ---------------------- 2 files changed, 29 insertions(+), 29 deletions(-) create mode 100644 app/assets/javascripts/diff_notes/components/new_issue_for_discussion.js delete mode 100644 app/assets/javascripts/diff_notes/components/new_issue_for_discussion.js.es6 diff --git a/app/assets/javascripts/diff_notes/components/new_issue_for_discussion.js b/app/assets/javascripts/diff_notes/components/new_issue_for_discussion.js new file mode 100644 index 00000000000..e86bef47172 --- /dev/null +++ b/app/assets/javascripts/diff_notes/components/new_issue_for_discussion.js @@ -0,0 +1,29 @@ +/* global Vue */ +/* global CommentsStore */ + +(() => { + const NewIssueForDiscussion = Vue.extend({ + props: { + discussionId: { + type: String, + required: true, + }, + }, + data() { + return { + discussions: CommentsStore.state, + }; + }, + computed: { + discussion() { + return this.discussions[this.discussionId]; + }, + showButton() { + if (this.discussion) return !this.discussion.isResolved(); + return false; + }, + }, + }); + + Vue.component('new-issue-for-discussion-btn', NewIssueForDiscussion); +})(); diff --git a/app/assets/javascripts/diff_notes/components/new_issue_for_discussion.js.es6 b/app/assets/javascripts/diff_notes/components/new_issue_for_discussion.js.es6 deleted file mode 100644 index e86bef47172..00000000000 --- a/app/assets/javascripts/diff_notes/components/new_issue_for_discussion.js.es6 +++ /dev/null @@ -1,29 +0,0 @@ -/* global Vue */ -/* global CommentsStore */ - -(() => { - const NewIssueForDiscussion = Vue.extend({ - props: { - discussionId: { - type: String, - required: true, - }, - }, - data() { - return { - discussions: CommentsStore.state, - }; - }, - computed: { - discussion() { - return this.discussions[this.discussionId]; - }, - showButton() { - if (this.discussion) return !this.discussion.isResolved(); - return false; - }, - }, - }); - - Vue.component('new-issue-for-discussion-btn', NewIssueForDiscussion); -})(); -- cgit v1.2.1 From a07e89b2751f1855e2fe4bdf05a5967fd7421778 Mon Sep 17 00:00:00 2001 From: Bob Van Landuyt Date: Thu, 9 Mar 2017 18:42:50 +0100 Subject: Pass the discussion to resolve after verifying captcha --- app/views/projects/issues/verify.html.haml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/views/projects/issues/verify.html.haml b/app/views/projects/issues/verify.html.haml index 09aa401e44a..a3e394a0add 100644 --- a/app/views/projects/issues/verify.html.haml +++ b/app/views/projects/issues/verify.html.haml @@ -2,3 +2,4 @@ = render layout: 'layouts/recaptcha_verification', locals: { spammable: @issue, form: form } do = hidden_field_tag(:merge_request_for_resolving_discussions, params[:merge_request_for_resolving_discussions]) + = hidden_field_tag(:discussion_to_resolve, params[:discussion_to_resolve]) -- cgit v1.2.1 From ea70a0d674539d53e40c63a0a3cebef33d7e13b7 Mon Sep 17 00:00:00 2001 From: Bob Van Landuyt Date: Fri, 10 Mar 2017 09:19:12 +0100 Subject: Rename variable merge_request_for_resolving_discussions -> merge_request_to_resolve_discussions_of --- app/controllers/projects/issues_controller.rb | 7 +++---- app/services/issues/build_service.rb | 8 ++++---- app/services/issues/create_service.rb | 2 +- app/services/issues/resolve_discussions.rb | 18 +++++++++--------- .../_new_issue_for_all_discussions.html.haml | 2 +- .../discussions/_new_issue_for_discussion.html.haml | 2 +- app/views/projects/issues/verify.html.haml | 2 +- .../widget/open/_unresolved_discussions.html.haml | 2 +- app/views/shared/issuable/_form.html.haml | 10 +++++----- doc/api/issues.md | 4 ++-- lib/api/issues.rb | 4 ++-- spec/controllers/projects/issues_controller_spec.rb | 8 ++++---- ...eate_issue_for_discussions_in_merge_request_spec.rb | 12 ++++++------ ...ate_issue_for_single_discussion_in_merge_request.rb | 6 +++--- spec/requests/api/issues_spec.rb | 4 ++-- spec/services/issues/build_service_spec.rb | 14 +++++++------- spec/services/issues/create_service_spec.rb | 8 ++++---- spec/services/issues/resolve_discussions_spec.rb | 14 +++++++------- .../resolving_discussions_in_issues_shared_examples.rb | 2 +- 19 files changed, 64 insertions(+), 65 deletions(-) diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 35d1a52b251..91fe6662164 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -65,13 +65,13 @@ class Projects::IssuesController < Projects::ApplicationController assignee_id: "" ) build_params = issue_params.merge( - merge_request_for_resolving_discussions: params[:merge_request_for_resolving_discussions], + merge_request_to_resolve_discussions_of: params[:merge_request_to_resolve_discussions_of], discussion_to_resolve: params[:discussion_to_resolve] ) service = Issues::BuildService.new(project, current_user, build_params) @issue = @noteable = service.execute - @merge_request_for_resolving_discussions = service.merge_request_for_resolving_discussions + @merge_request_to_resolve_discussions_of = service.merge_request_to_resolve_discussions_of @discussion_to_resolve = service.discussions_to_resolve.first if params[:discussion_to_resolve] respond_with(@issue) @@ -102,7 +102,7 @@ class Projects::IssuesController < Projects::ApplicationController def create create_params = issue_params.merge(spammable_params).merge( - merge_request_for_resolving_discussions: params[:merge_request_for_resolving_discussions], + merge_request_to_resolve_discussions_of: params[:merge_request_to_resolve_discussions_of], discussion_to_resolve: params[:discussion_to_resolve] ) @@ -248,7 +248,6 @@ class Projects::IssuesController < Projects::ApplicationController def issue_params params.require(:issue).permit( :title, :assignee_id, :position, :description, :confidential, - :discussion_to_resolve, :merge_request_for_resolving_discussions, :milestone_id, :due_date, :state_event, :task_num, :lock_version, label_ids: [] ) end diff --git a/app/services/issues/build_service.rb b/app/services/issues/build_service.rb index 3d4b6b055c4..510b32a77ab 100644 --- a/app/services/issues/build_service.rb +++ b/app/services/issues/build_service.rb @@ -8,23 +8,23 @@ module Issues end def issue_params_with_info_from_discussions - return {} unless merge_request_for_resolving_discussions + return {} unless merge_request_to_resolve_discussions_of { title: title_from_merge_request, description: description_for_discussions } end def title_from_merge_request - "Follow-up from \"#{merge_request_for_resolving_discussions.title}\"" + "Follow-up from \"#{merge_request_to_resolve_discussions_of.title}\"" end def description_for_discussions if discussions_to_resolve.empty? return "There are no unresolved discussions. "\ - "Review the conversation in #{merge_request_for_resolving_discussions.to_reference}" + "Review the conversation in #{merge_request_to_resolve_discussions_of.to_reference}" end description = "The following #{'discussion'.pluralize(discussions_to_resolve.size)} "\ - "from #{merge_request_for_resolving_discussions.to_reference} "\ + "from #{merge_request_to_resolve_discussions_of.to_reference} "\ "should be addressed:" [description, *items_for_discussions].join("\n\n") diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb index 175312aed91..3cf4b82b9f2 100644 --- a/app/services/issues/create_service.rb +++ b/app/services/issues/create_service.rb @@ -29,7 +29,7 @@ module Issues return if discussions_to_resolve.empty? Discussions::ResolveService.new(project, current_user, - merge_request: merge_request_for_resolving_discussions, + merge_request: merge_request_to_resolve_discussions_of, follow_up_issue: issue). execute(discussions_to_resolve) end diff --git a/app/services/issues/resolve_discussions.rb b/app/services/issues/resolve_discussions.rb index 89105dceb5f..297c7d696c3 100644 --- a/app/services/issues/resolve_discussions.rb +++ b/app/services/issues/resolve_discussions.rb @@ -1,30 +1,30 @@ module Issues module ResolveDiscussions - attr_reader :merge_request_for_resolving_discussions_iid, :discussion_to_resolve_id + attr_reader :merge_request_to_resolve_discussions_of_iid, :discussion_to_resolve_id def filter_resolve_discussion_params - @merge_request_for_resolving_discussions_iid ||= params.delete(:merge_request_for_resolving_discussions) + @merge_request_to_resolve_discussions_of_iid ||= params.delete(:merge_request_to_resolve_discussions_of) @discussion_to_resolve_id ||= params.delete(:discussion_to_resolve) end - def merge_request_for_resolving_discussions - return @merge_request_for_resolving_discussions if defined?(@merge_request_for_resolving_discussions) + def merge_request_to_resolve_discussions_of + return @merge_request_to_resolve_discussions_of if defined?(@merge_request_to_resolve_discussions_of) - @merge_request_for_resolving_discussions = MergeRequestsFinder.new(current_user, project_id: project.id). + @merge_request_to_resolve_discussions_of = MergeRequestsFinder.new(current_user, project_id: project.id). execute. - find_by(iid: merge_request_for_resolving_discussions_iid) + find_by(iid: merge_request_to_resolve_discussions_of_iid) end def discussions_to_resolve - return [] unless merge_request_for_resolving_discussions + return [] unless merge_request_to_resolve_discussions_of @discussions_to_resolve ||= if discussion_to_resolve_id - discussion_or_nil = merge_request_for_resolving_discussions + discussion_or_nil = merge_request_to_resolve_discussions_of .find_diff_discussion(discussion_to_resolve_id) Array(discussion_or_nil) else - merge_request_for_resolving_discussions + merge_request_to_resolve_discussions_of .resolvable_discussions end end diff --git a/app/views/discussions/_new_issue_for_all_discussions.html.haml b/app/views/discussions/_new_issue_for_all_discussions.html.haml index 01c5a7b4e54..ca9e0e8728a 100644 --- a/app/views/discussions/_new_issue_for_all_discussions.html.haml +++ b/app/views/discussions/_new_issue_for_all_discussions.html.haml @@ -3,4 +3,4 @@ .btn.btn-default.discussion-create-issue-btn.has-tooltip{ title: "Resolve all discussions in new issue", "aria-label" => "Resolve all discussions in a new issue", "data-container" => "body" } - = link_to custom_icon('icon_mr_issue'), new_namespace_project_issue_path(@project.namespace, @project, merge_request_for_resolving_discussions: merge_request.iid), title: "Resolve all discussions in new issue", class: 'new-issue-for-discussion' + = link_to custom_icon('icon_mr_issue'), new_namespace_project_issue_path(@project.namespace, @project, merge_request_to_resolve_discussions_of: merge_request.iid), title: "Resolve all discussions in new issue", class: 'new-issue-for-discussion' diff --git a/app/views/discussions/_new_issue_for_discussion.html.haml b/app/views/discussions/_new_issue_for_discussion.html.haml index c21ec0ecc3d..df5546a1e32 100644 --- a/app/views/discussions/_new_issue_for_discussion.html.haml +++ b/app/views/discussions/_new_issue_for_discussion.html.haml @@ -5,4 +5,4 @@ .btn.btn-default.discussion-create-issue-btn.has-tooltip{ title: "Resolve this discussion in a new issue", "aria-label" => "Resolve this discussion in a new issue", "data-container" => "body" } - = link_to custom_icon('icon_mr_issue'), new_namespace_project_issue_path(@project.namespace, @project, merge_request_for_resolving_discussions: merge_request.iid, discussion_to_resolve: discussion.id), title: "Resolve this discussion in a new issue", class: 'new-issue-for-discussion' + = link_to custom_icon('icon_mr_issue'), new_namespace_project_issue_path(@project.namespace, @project, merge_request_to_resolve_discussions_of: merge_request.iid, discussion_to_resolve: discussion.id), title: "Resolve this discussion in a new issue", class: 'new-issue-for-discussion' diff --git a/app/views/projects/issues/verify.html.haml b/app/views/projects/issues/verify.html.haml index a3e394a0add..6da7c317f3a 100644 --- a/app/views/projects/issues/verify.html.haml +++ b/app/views/projects/issues/verify.html.haml @@ -1,5 +1,5 @@ - form = [@project.namespace.becomes(Namespace), @project, @issue] = render layout: 'layouts/recaptcha_verification', locals: { spammable: @issue, form: form } do - = hidden_field_tag(:merge_request_for_resolving_discussions, params[:merge_request_for_resolving_discussions]) + = hidden_field_tag(:merge_request_to_resolve_discussions_of, params[:merge_request_to_resolve_discussions_of]) = hidden_field_tag(:discussion_to_resolve, params[:discussion_to_resolve]) diff --git a/app/views/projects/merge_requests/widget/open/_unresolved_discussions.html.haml b/app/views/projects/merge_requests/widget/open/_unresolved_discussions.html.haml index e094f97f3b6..ec9346ce89b 100644 --- a/app/views/projects/merge_requests/widget/open/_unresolved_discussions.html.haml +++ b/app/views/projects/merge_requests/widget/open/_unresolved_discussions.html.haml @@ -6,5 +6,5 @@ Please resolve these discussions - if @project.issues_enabled? && can?(current_user, :create_issue, @project) or - = link_to "open an issue to resolve them later", new_namespace_project_issue_path(@project.namespace, @project, merge_request_for_resolving_discussions: @merge_request.iid) + = link_to "open an issue to resolve them later", new_namespace_project_issue_path(@project.namespace, @project, merge_request_to_resolve_discussions_of: @merge_request.iid) to allow this merge request to be merged. diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index 9e7b48ca79c..0b0f2c9cd1a 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -45,23 +45,23 @@ = render 'shared/issuable/form/merge_params', issuable: issuable -- if @merge_request_for_resolving_discussions +- if @merge_request_to_resolve_discussions_of .form-group .col-sm-10.col-sm-offset-2 = icon('info-circle') - - if @merge_request_for_resolving_discussions.discussions_can_be_resolved_by?(current_user) - = hidden_field_tag 'merge_request_for_resolving_discussions', @merge_request_for_resolving_discussions.iid + - if @merge_request_to_resolve_discussions_of.discussions_can_be_resolved_by?(current_user) + = hidden_field_tag 'merge_request_to_resolve_discussions_of', @merge_request_to_resolve_discussions_of.iid - if @discussion_to_resolve = hidden_field_tag 'discussion_to_resolve', @discussion_to_resolve.id Creating this issue will resolve the discussion in - else Creating this issue will resolve all discussions in - = link_to_discussions_to_resolve(@merge_request_for_resolving_discussions, @discussion_to_resolve) + = link_to_discussions_to_resolve(@merge_request_to_resolve_discussions_of, @discussion_to_resolve) - else The = @discussion_to_resolve ? 'discussion' : 'discussions' at - = link_to_discussions_to_resolve(@merge_request_for_resolving_discussions, @discussion_to_resolve) + = link_to_discussions_to_resolve(@merge_request_to_resolve_discussions_of, @discussion_to_resolve) will stay unresolved. Ask someone with permission to resolve = @discussion_to_resolve ? 'it.' : 'them.' diff --git a/doc/api/issues.md b/doc/api/issues.md index 05fb9739ed1..cb437ffb174 100644 --- a/doc/api/issues.md +++ b/doc/api/issues.md @@ -340,8 +340,8 @@ POST /projects/:id/issues | `labels` | string | no | Comma-separated label names for an issue | | `created_at` | string | no | Date time string, ISO 8601 formatted, e.g. `2016-03-11T03:45:40Z` (requires admin or project owner rights) | | `due_date` | string | no | Date time string in the format YEAR-MONTH-DAY, e.g. `2016-03-11` | -| `merge_request_for_resolving_discussions` | integer | no | The IID of a merge request in which to resolve all issues. This will fill the issue with a default description and mark all discussions as resolved. When passing a description or title, these values will take precedence over the default values. | -| `discussion_to_resolve` | string | no | The ID of a discussion to resolve. This will fill in the issue with a default description and mark the discussion as resolved. Use in combination with `merge_request_for_resolving_discussions`. | +| `merge_request_to_resolve_discussions_of` | integer | no | The IID of a merge request in which to resolve all issues. This will fill the issue with a default description and mark all discussions as resolved. When passing a description or title, these values will take precedence over the default values. | +| `discussion_to_resolve` | string | no | The ID of a discussion to resolve. This will fill in the issue with a default description and mark the discussion as resolved. Use in combination with `merge_request_to_resolve_discussions_of`. | ```bash curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/4/issues?title=Issues%20with%20auth&labels=bug diff --git a/lib/api/issues.rb b/lib/api/issues.rb index 162e13a313d..1abe8639445 100644 --- a/lib/api/issues.rb +++ b/lib/api/issues.rb @@ -116,10 +116,10 @@ module API requires :title, type: String, desc: 'The title of an issue' optional :created_at, type: DateTime, desc: 'Date time when the issue was created. Available only for admins and project owners.' - optional :merge_request_for_resolving_discussions, type: Integer, + optional :merge_request_to_resolve_discussions_of, type: Integer, desc: 'The IID of a merge request for which to resolve discussions' optional :discussion_to_resolve, type: String, - desc: 'The ID of a discussion to resolve, also pass `merge_request_for_resolving_discussions`' + desc: 'The ID of a discussion to resolve, also pass `merge_request_to_resolve_discussions_of`' use :issue_params end post ':id/issues' do diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb index 5e70966aa2d..6ceaf96f78f 100644 --- a/spec/controllers/projects/issues_controller_spec.rb +++ b/spec/controllers/projects/issues_controller_spec.rb @@ -104,7 +104,7 @@ describe Projects::IssuesController do project_with_repository.team << [user, :developer] mr = create(:merge_request_with_diff_notes, source_project: project_with_repository) - get :new, namespace_id: project_with_repository.namespace, project_id: project_with_repository, merge_request_for_resolving_discussions: mr.iid + get :new, namespace_id: project_with_repository.namespace, project_id: project_with_repository, merge_request_to_resolve_discussions_of: mr.iid expect(assigns(:issue).title).not_to be_empty expect(assigns(:issue).description).not_to be_empty @@ -113,7 +113,7 @@ describe Projects::IssuesController do it 'fills in an issue for a discussion' do note = create(:note_on_merge_request, project: project) - get :new, namespace_id: project.namespace.path, project_id: project, merge_request_for_resolving_discussions: note.noteable.iid, discussion_to_resolve: note.discussion_id + get :new, namespace_id: project.namespace.path, project_id: project, merge_request_to_resolve_discussions_of: note.noteable.iid, discussion_to_resolve: note.discussion_id expect(assigns(:issue).title).not_to be_empty expect(assigns(:issue).description).not_to be_empty @@ -471,11 +471,11 @@ describe Projects::IssuesController do end let(:merge_request_params) do - { merge_request_for_resolving_discussions: merge_request.iid } + { merge_request_to_resolve_discussions_of: merge_request.iid } end def post_issue(issue_params, other_params: {}) - post :create, { namespace_id: project.namespace.to_param, project_id: project, issue: issue_params, merge_request_for_resolving_discussions: merge_request.iid }.merge(other_params) + post :create, { namespace_id: project.namespace.to_param, project_id: project, issue: issue_params, merge_request_to_resolve_discussions_of: merge_request.iid }.merge(other_params) end it 'creates an issue for the project' do diff --git a/spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb b/spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb index 6372432aa0d..572bca3de21 100644 --- a/spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb +++ b/spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb @@ -15,7 +15,7 @@ feature 'Resolving all open discussions in a merge request from an issue', featu it 'shows a button to resolve all discussions by creating a new issue' do within('li#resolve-count-app') do - expect(page).to have_link "Resolve all discussions in new issue", href: new_namespace_project_issue_path(project.namespace, project, merge_request_for_resolving_discussions: merge_request.iid) + expect(page).to have_link "Resolve all discussions in new issue", href: new_namespace_project_issue_path(project.namespace, project, merge_request_to_resolve_discussions_of: merge_request.iid) end end @@ -25,13 +25,13 @@ feature 'Resolving all open discussions in a merge request from an issue', featu end it 'hides the link for creating a new issue' do - expect(page).not_to have_link "Resolve all discussions in new issue", href: new_namespace_project_issue_path(project.namespace, project, merge_request_for_resolving_discussions: merge_request.iid) + expect(page).not_to have_link "Resolve all discussions in new issue", href: new_namespace_project_issue_path(project.namespace, project, merge_request_to_resolve_discussions_of: merge_request.iid) end end context 'creating an issue for discussions' do before do - click_link "Resolve all discussions in new issue", href: new_namespace_project_issue_path(project.namespace, project, merge_request_for_resolving_discussions: merge_request.iid) + click_link "Resolve all discussions in new issue", href: new_namespace_project_issue_path(project.namespace, project, merge_request_to_resolve_discussions_of: merge_request.iid) end it_behaves_like 'creating an issue for a discussion' @@ -64,13 +64,13 @@ feature 'Resolving all open discussions in a merge request from an issue', featu it 'has a link to resolve all discussions by creating an issue' do page.within '.mr-widget-body' do - expect(page).to have_link 'open an issue to resolve them later', href: new_namespace_project_issue_path(project.namespace, project, merge_request_for_resolving_discussions: merge_request.iid) + expect(page).to have_link 'open an issue to resolve them later', href: new_namespace_project_issue_path(project.namespace, project, merge_request_to_resolve_discussions_of: merge_request.iid) end end context 'creating an issue for discussions' do before do - page.click_link 'open an issue to resolve them later', href: new_namespace_project_issue_path(project.namespace, project, merge_request_for_resolving_discussions: merge_request.iid) + page.click_link 'open an issue to resolve them later', href: new_namespace_project_issue_path(project.namespace, project, merge_request_to_resolve_discussions_of: merge_request.iid) end it_behaves_like 'creating an issue for a discussion' @@ -83,7 +83,7 @@ feature 'Resolving all open discussions in a merge request from an issue', featu before do project.team << [user, :reporter] login_as user - visit new_namespace_project_issue_path(project.namespace, project, merge_request_for_resolving_discussions: merge_request.iid) + visit new_namespace_project_issue_path(project.namespace, project, merge_request_to_resolve_discussions_of: merge_request.iid) end it 'Shows a notice to ask someone else to resolve the discussions' do diff --git a/spec/features/issues/create_issue_for_single_discussion_in_merge_request.rb b/spec/features/issues/create_issue_for_single_discussion_in_merge_request.rb index 6d618505fde..88e2cc60d79 100644 --- a/spec/features/issues/create_issue_for_single_discussion_in_merge_request.rb +++ b/spec/features/issues/create_issue_for_single_discussion_in_merge_request.rb @@ -43,14 +43,14 @@ feature 'Resolve an open discussion in a merge request by creating an issue', fe end it 'has a link to create a new issue for a discussion' do - new_issue_link = new_namespace_project_issue_path(project.namespace, project, discussion_to_resolve: discussion.id, merge_request_for_resolving_discussions: merge_request.iid) + new_issue_link = new_namespace_project_issue_path(project.namespace, project, discussion_to_resolve: discussion.id, merge_request_to_resolve_discussions_of: merge_request.iid) expect(page).to have_link 'Resolve this discussion in a new issue', href: new_issue_link end context 'creating the issue' do before do - click_link 'Resolve this discussion in a new issue', href: new_namespace_project_issue_path(project.namespace, project, discussion_to_resolve: discussion.id, merge_request_for_resolving_discussions: merge_request.iid) + click_link 'Resolve this discussion in a new issue', href: new_namespace_project_issue_path(project.namespace, project, discussion_to_resolve: discussion.id, merge_request_to_resolve_discussions_of: merge_request.iid) end it 'has a hidden field for the discussion' do @@ -68,7 +68,7 @@ feature 'Resolve an open discussion in a merge request by creating an issue', fe project.team << [user, :reporter] login_as user visit new_namespace_project_issue_path(project.namespace, project, - merge_request_for_resolving_discussions: merge_request.iid, + merge_request_to_resolve_discussions_of: merge_request.iid, discussion_to_resolve: discussion.id) end diff --git a/spec/requests/api/issues_spec.rb b/spec/requests/api/issues_spec.rb index 26997ac0107..de7dbca0b22 100644 --- a/spec/requests/api/issues_spec.rb +++ b/spec/requests/api/issues_spec.rb @@ -941,7 +941,7 @@ describe API::Issues, api: true do before do post api("/projects/#{project.id}/issues", user), title: 'New Issue', - merge_request_for_resolving_discussions: merge_request.iid + merge_request_to_resolve_discussions_of: merge_request.iid end it_behaves_like 'creating an issue resolving discussions through the API' @@ -951,7 +951,7 @@ describe API::Issues, api: true do before do post api("/projects/#{project.id}/issues", user), title: 'New Issue', - merge_request_for_resolving_discussions: merge_request.iid, + merge_request_to_resolve_discussions_of: merge_request.iid, discussion_to_resolve: discussion.id end diff --git a/spec/services/issues/build_service_spec.rb b/spec/services/issues/build_service_spec.rb index 79b093e51fb..1dd53236fbd 100644 --- a/spec/services/issues/build_service_spec.rb +++ b/spec/services/issues/build_service_spec.rb @@ -12,7 +12,7 @@ describe Issues::BuildService, services: true do describe '#execute' do let(:merge_request) { create(:merge_request, title: "Hello world", source_project: project) } let(:discussion) { Discussion.new([create(:diff_note_on_merge_request, project: project, noteable: merge_request, note: "Almost done")]) } - let(:service) { described_class.new(project, user, merge_request_for_resolving_discussions: merge_request.iid, discussion_to_resolve: discussion.id) } + let(:service) { described_class.new(project, user, merge_request_to_resolve_discussions_of: merge_request.iid, discussion_to_resolve: discussion.id) } it 'references the noteable title in the issue title' do issue = service.execute @@ -30,12 +30,12 @@ describe Issues::BuildService, services: true do context 'for discussions in a merge request' do let(:merge_request) { create(:merge_request_with_diff_notes, source_project: project) } - let(:issue) { described_class.new(project, user, merge_request_for_resolving_discussions: merge_request.iid).execute } + let(:issue) { described_class.new(project, user, merge_request_to_resolve_discussions_of: merge_request.iid).execute } describe '#items_for_discussions' do it 'has an item for each discussion' do create(:diff_note_on_merge_request, noteable: merge_request, project: merge_request.source_project, line_number: 13) - service = described_class.new(project, user, merge_request_for_resolving_discussions: merge_request.iid) + service = described_class.new(project, user, merge_request_to_resolve_discussions_of: merge_request.iid) service.execute @@ -44,7 +44,7 @@ describe Issues::BuildService, services: true do end describe '#item_for_discussion' do - let(:service) { described_class.new(project, user, merge_request_for_resolving_discussions: merge_request.iid) } + let(:service) { described_class.new(project, user, merge_request_to_resolve_discussions_of: merge_request.iid) } it 'mentions the author of the note' do discussion = Discussion.new([create(:diff_note_on_merge_request, author: create(:user, username: 'author'))]) @@ -76,7 +76,7 @@ describe Issues::BuildService, services: true do it 'does not assign title when a title was given' do issue = described_class.new(project, user, - merge_request_for_resolving_discussions: merge_request, + merge_request_to_resolve_discussions_of: merge_request, title: 'What an issue').execute expect(issue.title).to eq('What an issue') @@ -84,7 +84,7 @@ describe Issues::BuildService, services: true do it 'does not assign description when a description was given' do issue = described_class.new(project, user, - merge_request_for_resolving_discussions: merge_request, + merge_request_to_resolve_discussions_of: merge_request, description: 'Fix at your earliest conveignance').execute expect(issue.description).to eq('Fix at your earliest conveignance') @@ -122,7 +122,7 @@ describe Issues::BuildService, services: true do describe '#execute' do it 'mentions the merge request in the description' do - issue = described_class.new(project, user, merge_request_for_resolving_discussions: merge_request.iid).execute + issue = described_class.new(project, user, merge_request_to_resolve_discussions_of: merge_request.iid).execute expect(issue.description).to include("Review the conversation in #{merge_request.to_reference}") end diff --git a/spec/services/issues/create_service_spec.rb b/spec/services/issues/create_service_spec.rb index 77a984df2d5..776cbc4296b 100644 --- a/spec/services/issues/create_service_spec.rb +++ b/spec/services/issues/create_service_spec.rb @@ -150,7 +150,7 @@ describe Issues::CreateService, services: true do end describe 'for a single discussion' do - let(:opts) { { discussion_to_resolve: discussion.id, merge_request_for_resolving_discussions: merge_request.iid } } + let(:opts) { { discussion_to_resolve: discussion.id, merge_request_to_resolve_discussions_of: merge_request.iid } } it 'resolves the discussion' do described_class.new(project, user, opts).execute @@ -176,7 +176,7 @@ describe Issues::CreateService, services: true do it 'can set nil explicitly to the title and description' do issue = described_class.new(project, user, - merge_request_for_resolving_discussions: merge_request, + merge_request_to_resolve_discussions_of: merge_request, description: nil, title: nil).execute @@ -186,7 +186,7 @@ describe Issues::CreateService, services: true do end describe 'for a merge request' do - let(:opts) { { merge_request_for_resolving_discussions: merge_request.iid } } + let(:opts) { { merge_request_to_resolve_discussions_of: merge_request.iid } } it 'resolves the discussion' do described_class.new(project, user, opts).execute @@ -212,7 +212,7 @@ describe Issues::CreateService, services: true do it 'can set nil explicitly to the title and description' do issue = described_class.new(project, user, - merge_request_for_resolving_discussions: merge_request, + merge_request_to_resolve_discussions_of: merge_request, description: nil, title: nil).execute diff --git a/spec/services/issues/resolve_discussions_spec.rb b/spec/services/issues/resolve_discussions_spec.rb index a7175398aa1..6cc738aec08 100644 --- a/spec/services/issues/resolve_discussions_spec.rb +++ b/spec/services/issues/resolve_discussions_spec.rb @@ -23,10 +23,10 @@ describe DummyService, services: true do let(:other_merge_request) { create(:merge_request, source_project: project, source_branch: "other") } describe "#merge_request_for_resolving_discussion" do - let(:service) { described_class.new(project, user, merge_request_for_resolving_discussions: merge_request.iid) } + let(:service) { described_class.new(project, user, merge_request_to_resolve_discussions_of: merge_request.iid) } it "finds the merge request" do - expect(service.merge_request_for_resolving_discussions).to eq(merge_request) + expect(service.merge_request_to_resolve_discussions_of).to eq(merge_request) end it "only queries for the merge request once" do @@ -37,7 +37,7 @@ describe DummyService, services: true do expect(fake_results).to receive(:find_by).exactly(1) expect(MergeRequestsFinder).to receive(:new).and_return(fake_finder).exactly(1) - 2.times { service.merge_request_for_resolving_discussions } + 2.times { service.merge_request_to_resolve_discussions_of } end end @@ -47,7 +47,7 @@ describe DummyService, services: true do project, user, discussion_to_resolve: discussion.id, - merge_request_for_resolving_discussions: merge_request.iid + merge_request_to_resolve_discussions_of: merge_request.iid ) # We need to compare discussion id's because the Discussion-objects are rebuilt # which causes the object-id's not to be different. @@ -64,7 +64,7 @@ describe DummyService, services: true do service = described_class.new( project, user, - merge_request_for_resolving_discussions: merge_request.iid + merge_request_to_resolve_discussions_of: merge_request.iid ) # We need to compare discussion id's because the Discussion-objects are rebuilt # which causes the object-id's not to be different. @@ -82,7 +82,7 @@ describe DummyService, services: true do service = described_class.new( project, user, - merge_request_for_resolving_discussions: merge_request.iid + merge_request_to_resolve_discussions_of: merge_request.iid ) # We need to compare discussion id's because the Discussion-objects are rebuilt # which causes the object-id's not to be different. @@ -96,7 +96,7 @@ describe DummyService, services: true do project, user, discussion_to_resolve: discussion.id, - merge_request_for_resolving_discussions: other_merge_request.iid + merge_request_to_resolve_discussions_of: other_merge_request.iid ) expect(service.discussions_to_resolve).to be_empty diff --git a/spec/support/features/resolving_discussions_in_issues_shared_examples.rb b/spec/support/features/resolving_discussions_in_issues_shared_examples.rb index c8e5021d9a6..4a946995f84 100644 --- a/spec/support/features/resolving_discussions_in_issues_shared_examples.rb +++ b/spec/support/features/resolving_discussions_in_issues_shared_examples.rb @@ -34,7 +34,7 @@ shared_examples 'creating an issue for a discussion' do end it 'has a hidden field for the merge request' do - merge_request_field = find('#merge_request_for_resolving_discussions', visible: false) + merge_request_field = find('#merge_request_to_resolve_discussions_of', visible: false) expect(merge_request_field.value).to eq(merge_request.iid.to_s) end -- cgit v1.2.1 From 4224124ddf5c47409af488c21caf10525edcde16 Mon Sep 17 00:00:00 2001 From: Bob Van Landuyt Date: Fri, 10 Mar 2017 13:05:35 +0100 Subject: Update copy One comments *on* something, not *in* something --- app/services/issues/build_service.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/issues/build_service.rb b/app/services/issues/build_service.rb index 510b32a77ab..77bced4bd5c 100644 --- a/app/services/issues/build_service.rb +++ b/app/services/issues/build_service.rb @@ -39,7 +39,7 @@ module Issues other_note_count = discussion.notes.size - 1 note_url = Gitlab::UrlBuilder.build(first_note) - discussion_info = "- [ ] #{first_note.author.to_reference} commented in a [discussion](#{note_url}): " + discussion_info = "- [ ] #{first_note.author.to_reference} commented on a [discussion](#{note_url}): " discussion_info << " (+#{other_note_count} #{'comment'.pluralize(other_note_count)})" if other_note_count > 0 note_without_block_quotes = Banzai::Filter::BlockquoteFenceFilter.new(first_note.note).call -- cgit v1.2.1 From 9541bad2647360c35916b6a402122501b5374c29 Mon Sep 17 00:00:00 2001 From: Bob Van Landuyt Date: Fri, 10 Mar 2017 13:36:50 +0100 Subject: Check `service.discussion_to_resolve_id` to decide content of flash That way the check is the same in the service & the controller --- app/controllers/projects/issues_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 91fe6662164..f2fee62ebd6 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -110,7 +110,7 @@ class Projects::IssuesController < Projects::ApplicationController @issue = service.execute if service.discussions_to_resolve.count(&:resolved?) > 0 - flash[:notice] = if params[:discussion_to_resolve].present? + flash[:notice] = if service.discussion_to_resolve_id "Resolved 1 discussion." else "Resolved all discussions." -- cgit v1.2.1 From 0c6e685f8c6320312ed2272abc59be926e006d7c Mon Sep 17 00:00:00 2001 From: Bob Van Landuyt Date: Fri, 10 Mar 2017 13:51:50 +0100 Subject: Move `ResolveDiscussions` into `concerns/issues` folder --- .../concerns/issues/resolve_discussions.rb | 32 ++++++++++++++++++++++ app/services/issues/resolve_discussions.rb | 32 ---------------------- config/application.rb | 3 +- 3 files changed, 34 insertions(+), 33 deletions(-) create mode 100644 app/services/concerns/issues/resolve_discussions.rb delete mode 100644 app/services/issues/resolve_discussions.rb diff --git a/app/services/concerns/issues/resolve_discussions.rb b/app/services/concerns/issues/resolve_discussions.rb new file mode 100644 index 00000000000..297c7d696c3 --- /dev/null +++ b/app/services/concerns/issues/resolve_discussions.rb @@ -0,0 +1,32 @@ +module Issues + module ResolveDiscussions + attr_reader :merge_request_to_resolve_discussions_of_iid, :discussion_to_resolve_id + + def filter_resolve_discussion_params + @merge_request_to_resolve_discussions_of_iid ||= params.delete(:merge_request_to_resolve_discussions_of) + @discussion_to_resolve_id ||= params.delete(:discussion_to_resolve) + end + + def merge_request_to_resolve_discussions_of + return @merge_request_to_resolve_discussions_of if defined?(@merge_request_to_resolve_discussions_of) + + @merge_request_to_resolve_discussions_of = MergeRequestsFinder.new(current_user, project_id: project.id). + execute. + find_by(iid: merge_request_to_resolve_discussions_of_iid) + end + + def discussions_to_resolve + return [] unless merge_request_to_resolve_discussions_of + + @discussions_to_resolve ||= + if discussion_to_resolve_id + discussion_or_nil = merge_request_to_resolve_discussions_of + .find_diff_discussion(discussion_to_resolve_id) + Array(discussion_or_nil) + else + merge_request_to_resolve_discussions_of + .resolvable_discussions + end + end + end +end diff --git a/app/services/issues/resolve_discussions.rb b/app/services/issues/resolve_discussions.rb deleted file mode 100644 index 297c7d696c3..00000000000 --- a/app/services/issues/resolve_discussions.rb +++ /dev/null @@ -1,32 +0,0 @@ -module Issues - module ResolveDiscussions - attr_reader :merge_request_to_resolve_discussions_of_iid, :discussion_to_resolve_id - - def filter_resolve_discussion_params - @merge_request_to_resolve_discussions_of_iid ||= params.delete(:merge_request_to_resolve_discussions_of) - @discussion_to_resolve_id ||= params.delete(:discussion_to_resolve) - end - - def merge_request_to_resolve_discussions_of - return @merge_request_to_resolve_discussions_of if defined?(@merge_request_to_resolve_discussions_of) - - @merge_request_to_resolve_discussions_of = MergeRequestsFinder.new(current_user, project_id: project.id). - execute. - find_by(iid: merge_request_to_resolve_discussions_of_iid) - end - - def discussions_to_resolve - return [] unless merge_request_to_resolve_discussions_of - - @discussions_to_resolve ||= - if discussion_to_resolve_id - discussion_or_nil = merge_request_to_resolve_discussions_of - .find_diff_discussion(discussion_to_resolve_id) - Array(discussion_or_nil) - else - merge_request_to_resolve_discussions_of - .resolvable_discussions - end - end - end -end diff --git a/config/application.rb b/config/application.rb index 1cc092c4da1..98b2759a8a7 100644 --- a/config/application.rb +++ b/config/application.rb @@ -26,7 +26,8 @@ module Gitlab #{config.root}/app/models/hooks #{config.root}/app/models/members #{config.root}/app/models/project_services - #{config.root}/app/workers/concerns)) + #{config.root}/app/workers/concerns + #{config.root}/app/services/concerns)) config.generators.templates.push("#{config.root}/generator_templates") -- cgit v1.2.1 From 92f82773900882ce9769fc05ce214d240abd5a62 Mon Sep 17 00:00:00 2001 From: Bob Van Landuyt Date: Fri, 10 Mar 2017 19:24:09 +0100 Subject: Make API v3 work with the new param name --- lib/api/v3/issues.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/api/v3/issues.rb b/lib/api/v3/issues.rb index bb3535bedf8..258cbfed022 100644 --- a/lib/api/v3/issues.rb +++ b/lib/api/v3/issues.rb @@ -139,6 +139,7 @@ module API end issue_params = declared_params(include_missing: false) + issue_params = issue_params.merge(merge_request_to_resolve_discussions_of: issue_params.delete(:merge_request_for_resolving_discussions)) issue = ::Issues::CreateService.new(user_project, current_user, -- cgit v1.2.1 From b55936bf0beb2b21de793e3dfc350a6ca1445db1 Mon Sep 17 00:00:00 2001 From: Bob Van Landuyt Date: Sun, 12 Mar 2017 16:40:25 +0100 Subject: Don't instrument `app/services/concerns` --- config/initializers/8_metrics.rb | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/config/initializers/8_metrics.rb b/config/initializers/8_metrics.rb index a1517e6afc8..3e1657b8382 100644 --- a/config/initializers/8_metrics.rb +++ b/config/initializers/8_metrics.rb @@ -20,13 +20,17 @@ def instrument_classes(instrumentation) # Path to search => prefix to strip from constant paths_to_instrument = { - %w(app finders) => %w(app finders), - %w(app mailers emails) => %w(app mailers), - %w(app services **) => %w(app services), - %w(lib gitlab conflicts) => ['lib'], - %w(lib gitlab diff) => ['lib'], - %w(lib gitlab email message) => ['lib'], - %w(lib gitlab checks) => ['lib'] + %w(app finders) => %w(app finders), + %w(app mailers emails) => %w(app mailers), + # Don't instrument `app/services/concerns` + # It contains modules that are included in the services. + # The services themselves are instrumented so the methods from the modules + # are included. + %w(app services [^concerns]**) => %w(app services), + %w(lib gitlab conflicts) => ['lib'], + %w(lib gitlab diff) => ['lib'], + %w(lib gitlab email message) => ['lib'], + %w(lib gitlab checks) => ['lib'] } paths_to_instrument.each do |(path, prefix)| -- cgit v1.2.1 From de16915b214f04884e8eab8c1ada52a2a4818f7a Mon Sep 17 00:00:00 2001 From: Filipa Lacerda Date: Mon, 13 Mar 2017 10:21:06 +0000 Subject: Fix bad code example --- doc/development/frontend.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/development/frontend.md b/doc/development/frontend.md index c5ee44988ac..e7add17fe2d 100644 --- a/doc/development/frontend.md +++ b/doc/development/frontend.md @@ -66,7 +66,7 @@ Let's look into each of them: This is the index file of your new feature. This is where the root Vue instance of the new feature should be. -The Store and the Service should be loaded in this file and provided as a prop to the main component. +The Store and the Service should be imported and initialized in this file and provided as a prop to the main component. Don't forget to follow [these steps.][page_specific_javascript] @@ -353,7 +353,7 @@ Good: ```javascript class Foo { constructor(opts) { - opts.container.querySelector('.bar'); + document.querySelector(`${opts.container} .bar`); } } -- cgit v1.2.1 From 3f949a712f4eb0fcd64204ae5873381609f0c80d Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Mon, 13 Mar 2017 12:06:53 +0100 Subject: Improve fragment caching rules at projects dashboard --- app/views/shared/projects/_project.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml index 4a27965754d..7e9fb7bb4d3 100644 --- a/app/views/shared/projects/_project.html.haml +++ b/app/views/shared/projects/_project.html.haml @@ -7,7 +7,7 @@ - show_last_commit_as_description = false unless local_assigns[:show_last_commit_as_description] == true && project.commit - css_class += " no-description" if project.description.blank? && !show_last_commit_as_description - cache_key = [project.namespace, project, controller.controller_name, controller.action_name, current_application_settings, 'v2.3'] -- cache_key.push(project.commit.status) if project.commit.try(:status) +- cache_key.push(project.commit&.sha, project.commit&.status) %li.project-row{ class: css_class } = cache(cache_key) do -- cgit v1.2.1 From c5e34da01d6ef4717f934754c9c2d1beadd8c55e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Coutable?= Date: Mon, 13 Mar 2017 14:07:38 +0100 Subject: Remove explicit `require` calls, and use `require_dependency` when needed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit See http://guides.rubyonrails.org/autoloading_and_reloading_constants.html for more info. Signed-off-by: Rémy Coutable --- app/controllers/projects/wikis_controller.rb | 2 -- config/initializers/8_metrics.rb | 6 ++-- config/initializers/omniauth.rb | 9 ++---- lib/gitlab/ldap/user.rb | 2 -- lib/omni_auth/strategies/bitbucket.rb | 41 ++++++++++++++++++++++++++++ lib/omniauth/strategies/bitbucket.rb | 41 ---------------------------- 6 files changed, 47 insertions(+), 54 deletions(-) create mode 100644 lib/omni_auth/strategies/bitbucket.rb delete mode 100644 lib/omniauth/strategies/bitbucket.rb diff --git a/app/controllers/projects/wikis_controller.rb b/app/controllers/projects/wikis_controller.rb index 2d8064c9878..8b6c83d4fed 100644 --- a/app/controllers/projects/wikis_controller.rb +++ b/app/controllers/projects/wikis_controller.rb @@ -1,5 +1,3 @@ -require 'project_wiki' - class Projects::WikisController < Projects::ApplicationController before_action :authorize_read_wiki! before_action :authorize_create_wiki!, only: [:edit, :create, :history] diff --git a/config/initializers/8_metrics.rb b/config/initializers/8_metrics.rb index a1517e6afc8..b05e17a7f5c 100644 --- a/config/initializers/8_metrics.rb +++ b/config/initializers/8_metrics.rb @@ -120,9 +120,9 @@ if Gitlab::Metrics.enabled? # These are manually require'd so the classes are registered properly with # ActiveSupport. - require 'gitlab/metrics/subscribers/action_view' - require 'gitlab/metrics/subscribers/active_record' - require 'gitlab/metrics/subscribers/rails_cache' + require_dependency 'gitlab/metrics/subscribers/action_view' + require_dependency 'gitlab/metrics/subscribers/active_record' + require_dependency 'gitlab/metrics/subscribers/rails_cache' Gitlab::Application.configure do |config| config.middleware.use(Gitlab::Metrics::RackMiddleware) diff --git a/config/initializers/omniauth.rb b/config/initializers/omniauth.rb index ab5a0561b8c..f7fa6d1c2de 100644 --- a/config/initializers/omniauth.rb +++ b/config/initializers/omniauth.rb @@ -20,15 +20,12 @@ OmniAuth.config.before_request_phase do |env| end if Gitlab.config.omniauth.enabled - Gitlab.config.omniauth.providers.each do |provider| - if provider['name'] == 'kerberos' - require 'omniauth-kerberos' - end - end + provider_names = Gitlab.config.omniauth.providers.map(&:name) + require 'omniauth-kerberos' if provider_names.include?('kerberos') end module OmniAuth module Strategies - autoload :Bitbucket, Rails.root.join('lib', 'omniauth', 'strategies', 'bitbucket') + autoload :Bitbucket, Rails.root.join('lib', 'omni_auth', 'strategies', 'bitbucket') end end diff --git a/lib/gitlab/ldap/user.rb b/lib/gitlab/ldap/user.rb index b84c81f1a6c..2d5e47a6f3b 100644 --- a/lib/gitlab/ldap/user.rb +++ b/lib/gitlab/ldap/user.rb @@ -1,5 +1,3 @@ -require 'gitlab/o_auth/user' - # LDAP extension for User model # # * Find or create user from omniauth.auth data diff --git a/lib/omni_auth/strategies/bitbucket.rb b/lib/omni_auth/strategies/bitbucket.rb new file mode 100644 index 00000000000..5a7d67c2390 --- /dev/null +++ b/lib/omni_auth/strategies/bitbucket.rb @@ -0,0 +1,41 @@ +require 'omniauth-oauth2' + +module OmniAuth + module Strategies + class Bitbucket < OmniAuth::Strategies::OAuth2 + option :name, 'bitbucket' + + option :client_options, { + site: 'https://bitbucket.org', + authorize_url: 'https://bitbucket.org/site/oauth2/authorize', + token_url: 'https://bitbucket.org/site/oauth2/access_token' + } + + uid do + raw_info['username'] + end + + info do + { + name: raw_info['display_name'], + avatar: raw_info['links']['avatar']['href'], + email: primary_email + } + end + + def raw_info + @raw_info ||= access_token.get('api/2.0/user').parsed + end + + def primary_email + primary = emails.find { |i| i['is_primary'] && i['is_confirmed'] } + primary && primary['email'] || nil + end + + def emails + email_response = access_token.get('api/2.0/user/emails').parsed + @emails ||= email_response && email_response['values'] || nil + end + end + end +end diff --git a/lib/omniauth/strategies/bitbucket.rb b/lib/omniauth/strategies/bitbucket.rb deleted file mode 100644 index 5a7d67c2390..00000000000 --- a/lib/omniauth/strategies/bitbucket.rb +++ /dev/null @@ -1,41 +0,0 @@ -require 'omniauth-oauth2' - -module OmniAuth - module Strategies - class Bitbucket < OmniAuth::Strategies::OAuth2 - option :name, 'bitbucket' - - option :client_options, { - site: 'https://bitbucket.org', - authorize_url: 'https://bitbucket.org/site/oauth2/authorize', - token_url: 'https://bitbucket.org/site/oauth2/access_token' - } - - uid do - raw_info['username'] - end - - info do - { - name: raw_info['display_name'], - avatar: raw_info['links']['avatar']['href'], - email: primary_email - } - end - - def raw_info - @raw_info ||= access_token.get('api/2.0/user').parsed - end - - def primary_email - primary = emails.find { |i| i['is_primary'] && i['is_confirmed'] } - primary && primary['email'] || nil - end - - def emails - email_response = access_token.get('api/2.0/user/emails').parsed - @emails ||= email_response && email_response['values'] || nil - end - end - end -end -- cgit v1.2.1 From 2e1b2233ce92fadd81ce3e89e1cd1220b9730060 Mon Sep 17 00:00:00 2001 From: Tomasz Maczukin Date: Mon, 13 Mar 2017 16:35:10 +0100 Subject: Fix regression in runners registration v1 api --- lib/ci/api/runners.rb | 4 ++-- spec/requests/api/runner_spec.rb | 3 +++ spec/requests/ci/api/runners_spec.rb | 3 +++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/ci/api/runners.rb b/lib/ci/api/runners.rb index c1fd959ef14..45aa2adccf5 100644 --- a/lib/ci/api/runners.rb +++ b/lib/ci/api/runners.rb @@ -24,13 +24,13 @@ module Ci optional :locked, type: Boolean, desc: 'Lock this runner for this specific project' end post "register" do - runner_params = declared(params, include_missing: false) + runner_params = declared(params, include_missing: false).except(:token) runner = if runner_registration_token_valid? # Create shared runner. Requires admin access Ci::Runner.create(runner_params.merge(is_shared: true)) - elsif project = Project.find_by(runners_token: runner_params[:token]) + elsif project = Project.find_by(runners_token: params[:token]) # Create a specific runner for project. project.runners.create(runner_params) end diff --git a/spec/requests/api/runner_spec.rb b/spec/requests/api/runner_spec.rb index 15d458e0795..d50fe80b36a 100644 --- a/spec/requests/api/runner_spec.rb +++ b/spec/requests/api/runner_spec.rb @@ -39,6 +39,7 @@ describe API::Runner do expect(json_response['id']).to eq(runner.id) expect(json_response['token']).to eq(runner.token) expect(runner.run_untagged).to be true + expect(runner.token).not_to eq(registration_token) end context 'when project token is used' do @@ -49,6 +50,8 @@ describe API::Runner do expect(response).to have_http_status 201 expect(project.runners.size).to eq(1) + expect(Ci::Runner.first.token).not_to eq(registration_token) + expect(Ci::Runner.first.token).not_to eq(project.runners_token) end end end diff --git a/spec/requests/ci/api/runners_spec.rb b/spec/requests/ci/api/runners_spec.rb index 8719313783e..d50cdfdc2d6 100644 --- a/spec/requests/ci/api/runners_spec.rb +++ b/spec/requests/ci/api/runners_spec.rb @@ -18,6 +18,7 @@ describe Ci::API::Runners do it 'creates runner with default values' do expect(response).to have_http_status 201 expect(Ci::Runner.first.run_untagged).to be true + expect(Ci::Runner.first.token).not_to eq(registration_token) end end @@ -74,6 +75,8 @@ describe Ci::API::Runners do it 'creates runner' do expect(response).to have_http_status 201 expect(project.runners.size).to eq(1) + expect(Ci::Runner.first.token).not_to eq(registration_token) + expect(Ci::Runner.first.token).not_to eq(project.runners_token) end end -- cgit v1.2.1 From 5f7592d53805b18fbbc2a117ab8b4d953b13dbb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Coutable?= Date: Thu, 9 Mar 2017 00:41:02 +0100 Subject: Implement `json_response` as a `let` variable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is not a good idea to memoize `json_response` using an instance variable because `rspec-retry` doesn't clear instance variables on retries, only `let` variables. This will avoid issues where retries would fail on a different line that the original failure, blurrying what's the real failure. Also, automatically add api: true to specs under /spec/requests/(ci/)?api/, and include JsonHelpers in controller, request and API specs. Signed-off-by: Rémy Coutable --- spec/requests/api/projects_spec.rb | 4 ++-- spec/spec_helper.rb | 6 ++++++ spec/support/api_helpers.rb | 4 ---- spec/support/json_response_helpers.rb | 9 +++++++++ 4 files changed, 17 insertions(+), 6 deletions(-) create mode 100644 spec/support/json_response_helpers.rb diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb index b4b23617498..c481b7e72b1 100644 --- a/spec/requests/api/projects_spec.rb +++ b/spec/requests/api/projects_spec.rb @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- require 'spec_helper' -describe API::Projects, api: true do - include ApiHelpers +describe API::Projects, :api do include Gitlab::CurrentSettings + let(:user) { create(:user) } let(:user2) { create(:user) } let(:user3) { create(:user) } diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 5fda7c63cdb..068984aee84 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -43,8 +43,14 @@ RSpec.configure do |config| config.include ActiveSupport::Testing::TimeHelpers config.include StubGitlabCalls config.include StubGitlabData + config.include ApiHelpers, :api config.infer_spec_type_from_file_location! + + config.define_derived_metadata(file_path: %r{/spec/requests/(ci/)?api/}) do |metadata| + metadata[:api] = true + end + config.raise_errors_for_deprecations! config.before(:suite) do diff --git a/spec/support/api_helpers.rb b/spec/support/api_helpers.rb index ae6e708cf87..35d1e1cfc7d 100644 --- a/spec/support/api_helpers.rb +++ b/spec/support/api_helpers.rb @@ -49,8 +49,4 @@ module ApiHelpers '' end end - - def json_response - @_json_response ||= JSON.parse(response.body) - end end diff --git a/spec/support/json_response_helpers.rb b/spec/support/json_response_helpers.rb new file mode 100644 index 00000000000..e8d2ef2d7f0 --- /dev/null +++ b/spec/support/json_response_helpers.rb @@ -0,0 +1,9 @@ +shared_context 'JSON response' do + let(:json_response) { JSON.parse(response.body) } +end + +RSpec.configure do |config| + config.include_context 'JSON response', type: :controller + config.include_context 'JSON response', type: :request + config.include_context 'JSON response', :api +end -- cgit v1.2.1 From 1761da5152e460549e0e827990ea4f36d097db33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Coutable?= Date: Thu, 9 Mar 2017 13:12:28 +0100 Subject: Retry only on feature specs that use JS, on CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Rémy Coutable --- .gitlab-ci.yml | 2 -- spec/spec_helper.rb | 7 +++++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index deeb01f9a3c..db3d25195dc 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -7,8 +7,6 @@ cache: variables: MYSQL_ALLOW_EMPTY_PASSWORD: "1" - # retry tests only in CI environment - RSPEC_RETRY_RETRY_COUNT: "3" RAILS_ENV: "test" SIMPLECOV: "true" SETUP_DB: "true" diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 068984aee84..ceb3209331f 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -57,6 +57,13 @@ RSpec.configure do |config| TestEnv.init end + if ENV['CI'] + # Retry only on feature specs that use JS + config.around :each, :js do |ex| + ex.run_with_retry retry: 3 + end + end + config.around(:each, :caching) do |example| caching_store = Rails.cache Rails.cache = ActiveSupport::Cache::MemoryStore.new if example.metadata[:caching] -- cgit v1.2.1 From c0242485393fe93397ee18889bc5345b67d5ea0d Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Wed, 22 Feb 2017 19:27:06 -0600 Subject: Update permalink/blame buttons with line number fragment hash Fix https://gitlab.com/gitlab-org/gitlab-ce/issues/19742 --- .../blob/blob_line_permalink_updater.js | 35 ++++++++ app/assets/javascripts/dispatcher.js | 8 ++ app/assets/javascripts/line_highlighter.js | 12 +-- app/assets/stylesheets/framework/highlight.scss | 9 +- app/views/projects/blob/_actions.html.haml | 2 +- ...rmalink-blame-button-line-number-hash-links.yml | 4 + .../blobs/blob_line_permalink_updater_spec.rb | 97 ++++++++++++++++++++++ spec/javascripts/line_highlighter_spec.js | 18 +--- 8 files changed, 157 insertions(+), 28 deletions(-) create mode 100644 app/assets/javascripts/blob/blob_line_permalink_updater.js create mode 100644 changelogs/unreleased/19742-permalink-blame-button-line-number-hash-links.yml create mode 100644 spec/features/projects/blobs/blob_line_permalink_updater_spec.rb diff --git a/app/assets/javascripts/blob/blob_line_permalink_updater.js b/app/assets/javascripts/blob/blob_line_permalink_updater.js new file mode 100644 index 00000000000..c8f68860fbd --- /dev/null +++ b/app/assets/javascripts/blob/blob_line_permalink_updater.js @@ -0,0 +1,35 @@ +const lineNumberRe = /^L[0-9]+/; + +const updateLineNumbersOnBlobPermalinks = (linksToUpdate) => { + const hash = gl.utils.getLocationHash(); + if (hash && lineNumberRe.test(hash)) { + const hashUrlString = `#${hash}`; + + [].concat(Array.prototype.slice.call(linksToUpdate)).forEach((permalinkButton) => { + const baseHref = permalinkButton.getAttribute('data-original-href') || (() => { + const href = permalinkButton.getAttribute('href'); + permalinkButton.setAttribute('data-original-href', href); + return href; + })(); + permalinkButton.setAttribute('href', `${baseHref}${hashUrlString}`); + }); + } +}; + +function BlobLinePermalinkUpdater(blobContentHolder, lineNumberSelector, elementsToUpdate) { + const updateBlameAndBlobPermalinkCb = () => { + // Wait for the hash to update from the LineHighlighter callback + setTimeout(() => { + updateLineNumbersOnBlobPermalinks(elementsToUpdate); + }, 0); + }; + + blobContentHolder.addEventListener('click', (e) => { + if (e.target.matches(lineNumberSelector)) { + updateBlameAndBlobPermalinkCb(); + } + }); + updateBlameAndBlobPermalinkCb(); +} + +export default BlobLinePermalinkUpdater; diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index 7b9b9123c31..7956d6afff2 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -40,6 +40,7 @@ import BindInOut from './behaviors/bind_in_out'; import GroupsList from './groups_list'; import ProjectsList from './projects_list'; import MiniPipelineGraph from './mini_pipeline_graph_dropdown'; +import BlobLinePermalinkUpdater from './blob/blob_line_permalink_updater'; const ShortcutsBlob = require('./shortcuts_blob'); const UserCallout = require('./user_callout'); @@ -252,6 +253,13 @@ const UserCallout = require('./user_callout'); case 'projects:blob:show': case 'projects:blame:show': new LineHighlighter(); + + new BlobLinePermalinkUpdater( + document.querySelector('#blob-content-holder'), + '.diff-line-num[data-line-number]', + document.querySelectorAll('.js-data-file-blob-permalink-url, .js-blob-blame-link'), + ); + shortcut_handler = new ShortcutsNavigation(); const fileBlobPermalinkUrlElement = document.querySelector('.js-data-file-blob-permalink-url'); const fileBlobPermalinkUrl = fileBlobPermalinkUrlElement && fileBlobPermalinkUrlElement.getAttribute('href'); diff --git a/app/assets/javascripts/line_highlighter.js b/app/assets/javascripts/line_highlighter.js index 966fcd8ec47..1821ca18053 100644 --- a/app/assets/javascripts/line_highlighter.js +++ b/app/assets/javascripts/line_highlighter.js @@ -67,17 +67,7 @@ require('vendor/jquery.scrollTo'); } LineHighlighter.prototype.bindEvents = function() { - $('#blob-content-holder').on('mousedown', 'a[data-line-number]', this.clickHandler); - // While it may seem odd to bind to the mousedown event and then throw away - // the click event, there is a method to our madness. - // - // If not done this way, the line number anchor will sometimes keep its - // active state even when the event is cancelled, resulting in an ugly border - // around the link and/or a persisted underline text decoration. - $('#blob-content-holder').on('click', 'a[data-line-number]', function(event) { - event.preventDefault(); - event.stopPropagation(); - }); + $('#blob-content-holder').on('click', 'a[data-line-number]', this.clickHandler); }; LineHighlighter.prototype.clickHandler = function(event) { diff --git a/app/assets/stylesheets/framework/highlight.scss b/app/assets/stylesheets/framework/highlight.scss index 909a0f4afda..6d27d7568cf 100644 --- a/app/assets/stylesheets/framework/highlight.scss +++ b/app/assets/stylesheets/framework/highlight.scss @@ -57,8 +57,13 @@ visibility: hidden; } - &:hover i { - visibility: visible; + &:hover, + &:focus { + outline: none; + + & i { + visibility: visible; + } } } } diff --git a/app/views/projects/blob/_actions.html.haml b/app/views/projects/blob/_actions.html.haml index c44d8fcd430..14d42f7d9ec 100644 --- a/app/views/projects/blob/_actions.html.haml +++ b/app/views/projects/blob/_actions.html.haml @@ -12,7 +12,7 @@ class: 'btn btn-sm' - else = link_to 'Blame', namespace_project_blame_path(@project.namespace, @project, @id), - class: 'btn btn-sm' unless @blob.empty? + class: 'btn btn-sm js-blob-blame-link' unless @blob.empty? = link_to 'History', namespace_project_commits_path(@project.namespace, @project, @id), class: 'btn btn-sm' = link_to 'Permalink', namespace_project_blob_path(@project.namespace, @project, diff --git a/changelogs/unreleased/19742-permalink-blame-button-line-number-hash-links.yml b/changelogs/unreleased/19742-permalink-blame-button-line-number-hash-links.yml new file mode 100644 index 00000000000..199f1edec8b --- /dev/null +++ b/changelogs/unreleased/19742-permalink-blame-button-line-number-hash-links.yml @@ -0,0 +1,4 @@ +--- +title: Update permalink/blame buttons with line number fragment hash +merge_request: +author: diff --git a/spec/features/projects/blobs/blob_line_permalink_updater_spec.rb b/spec/features/projects/blobs/blob_line_permalink_updater_spec.rb new file mode 100644 index 00000000000..d94204230f6 --- /dev/null +++ b/spec/features/projects/blobs/blob_line_permalink_updater_spec.rb @@ -0,0 +1,97 @@ +require 'spec_helper' + +feature 'Blob button line permalinks (BlobLinePermalinkUpdater)', feature: true, js: true do + include TreeHelper + + let(:project) { create(:project, :public, :repository) } + let(:path) { 'CHANGELOG' } + let(:sha) { project.repository.commit.sha } + + describe 'On a file(blob)' do + def get_absolute_url(path = "") + "http://#{page.server.host}:#{page.server.port}#{path}" + end + + def visit_blob(fragment = nil) + visit namespace_project_blob_path(project.namespace, project, tree_join('master', path), anchor: fragment) + end + + describe 'Click "Permalink" button' do + it 'works with no initial line number fragment hash' do + visit_blob + + expect(find('.js-data-file-blob-permalink-url')['href']).to eq(get_absolute_url(namespace_project_blob_path(project.namespace, project, tree_join(sha, path)))) + end + + it 'maintains intitial fragment hash' do + fragment = "L3" + + visit_blob(fragment) + + expect(find('.js-data-file-blob-permalink-url')['href']).to eq(get_absolute_url(namespace_project_blob_path(project.namespace, project, tree_join(sha, path), anchor: fragment))) + end + + it 'changes fragment hash if line number clicked' do + ending_fragment = "L5" + + visit_blob + + find('#L3').click + find("##{ending_fragment}").click + + expect(find('.js-data-file-blob-permalink-url')['href']).to eq(get_absolute_url(namespace_project_blob_path(project.namespace, project, tree_join(sha, path), anchor: ending_fragment))) + end + + it 'with initial fragment hash, changes fragment hash if line number clicked' do + fragment = "L1" + ending_fragment = "L5" + + visit_blob(fragment) + + find('#L3').click + find("##{ending_fragment}").click + + expect(find('.js-data-file-blob-permalink-url')['href']).to eq(get_absolute_url(namespace_project_blob_path(project.namespace, project, tree_join(sha, path), anchor: ending_fragment))) + end + end + + describe 'Click "Blame" button' do + it 'works with no initial line number fragment hash' do + visit_blob + + expect(find('.js-blob-blame-link')['href']).to eq(get_absolute_url(namespace_project_blame_path(project.namespace, project, tree_join('master', path)))) + end + + it 'maintains intitial fragment hash' do + fragment = "L3" + + visit_blob(fragment) + + expect(find('.js-blob-blame-link')['href']).to eq(get_absolute_url(namespace_project_blame_path(project.namespace, project, tree_join('master', path), anchor: fragment))) + end + + it 'changes fragment hash if line number clicked' do + ending_fragment = "L5" + + visit_blob + + find('#L3').click + find("##{ending_fragment}").click + + expect(find('.js-blob-blame-link')['href']).to eq(get_absolute_url(namespace_project_blame_path(project.namespace, project, tree_join('master', path), anchor: ending_fragment))) + end + + it 'with initial fragment hash, changes fragment hash if line number clicked' do + fragment = "L1" + ending_fragment = "L5" + + visit_blob(fragment) + + find('#L3').click + find("##{ending_fragment}").click + + expect(find('.js-blob-blame-link')['href']).to eq(get_absolute_url(namespace_project_blame_path(project.namespace, project, tree_join('master', path), anchor: ending_fragment))) + end + end + end +end diff --git a/spec/javascripts/line_highlighter_spec.js b/spec/javascripts/line_highlighter_spec.js index a0b2ebc221b..a1fd2d38968 100644 --- a/spec/javascripts/line_highlighter_spec.js +++ b/spec/javascripts/line_highlighter_spec.js @@ -7,16 +7,12 @@ require('~/line_highlighter'); describe('LineHighlighter', function() { var clickLine; preloadFixtures('static/line_highlighter.html.raw'); - clickLine = function(number, eventData) { - var e; - if (eventData == null) { - eventData = {}; - } + clickLine = function(number, eventData = {}) { if ($.isEmptyObject(eventData)) { - return $("#L" + number).mousedown().click(); + return $("#L" + number).click(); } else { - e = $.Event('mousedown', eventData); - return $("#L" + number).trigger(e).click(); + const e = $.Event('click', eventData); + return $("#L" + number).trigger(e); } }; beforeEach(function() { @@ -63,12 +59,6 @@ require('~/line_highlighter'); }); }); describe('#clickHandler', function() { - it('discards the mousedown event', function() { - var spy; - spy = spyOnEvent('a[data-line-number]', 'mousedown'); - clickLine(13); - return expect(spy).toHaveBeenPrevented(); - }); it('handles clicking on a child icon element', function() { var spy; spy = spyOn(this["class"], 'setHash').and.callThrough(); -- cgit v1.2.1 From 34bf5d863ec1655e3681997c549d159fea6983a9 Mon Sep 17 00:00:00 2001 From: Adam Niedzielski Date: Mon, 13 Mar 2017 18:12:32 +0100 Subject: Display error message when deleting tag in web UI fails --- app/controllers/projects/tags_controller.rb | 22 ++++++++++++++---- app/services/tags/destroy_service.rb | 2 ++ app/views/projects/tags/destroy.js.haml | 4 +++- .../handle-failure-when-deleting-tags.yml | 4 ++++ spec/features/tags/master_deletes_tag_spec.rb | 27 +++++++++++++++++----- 5 files changed, 48 insertions(+), 11 deletions(-) create mode 100644 changelogs/unreleased/handle-failure-when-deleting-tags.yml diff --git a/app/controllers/projects/tags_controller.rb b/app/controllers/projects/tags_controller.rb index 33379659d73..ea7e4d9f663 100644 --- a/app/controllers/projects/tags_controller.rb +++ b/app/controllers/projects/tags_controller.rb @@ -41,13 +41,27 @@ class Projects::TagsController < Projects::ApplicationController end def destroy - Tags::DestroyService.new(project, current_user).execute(params[:id]) + result = Tags::DestroyService.new(project, current_user).execute(params[:id]) respond_to do |format| - format.html do - redirect_to namespace_project_tags_path(@project.namespace, @project) + if result[:status] == :success + format.html do + redirect_to namespace_project_tags_path(@project.namespace, @project) + end + + format.js + else + @error = result[:message] + + format.html do + redirect_to namespace_project_tags_path(@project.namespace, @project), + alert: @error + end + + format.js do + render status: :unprocessable_entity + end end - format.js end end end diff --git a/app/services/tags/destroy_service.rb b/app/services/tags/destroy_service.rb index 910b4f5e361..a368f4f5b61 100644 --- a/app/services/tags/destroy_service.rb +++ b/app/services/tags/destroy_service.rb @@ -21,6 +21,8 @@ module Tags else error('Failed to remove tag') end + rescue GitHooksService::PreReceiveError => ex + error(ex.message) end def error(message, return_code = 400) diff --git a/app/views/projects/tags/destroy.js.haml b/app/views/projects/tags/destroy.js.haml index e4a78fadbeb..cde23e03d54 100644 --- a/app/views/projects/tags/destroy.js.haml +++ b/app/views/projects/tags/destroy.js.haml @@ -1,2 +1,4 @@ -- if @repository.tags.empty? +- if @error.present? + new Flash('#{escape_javascript(@error)}', 'alert'); +- elsif @repository.tags.empty? $('.tags').load(document.URL + ' .nothing-here-block').hide().fadeIn(1000) diff --git a/changelogs/unreleased/handle-failure-when-deleting-tags.yml b/changelogs/unreleased/handle-failure-when-deleting-tags.yml new file mode 100644 index 00000000000..99b07c5fb5f --- /dev/null +++ b/changelogs/unreleased/handle-failure-when-deleting-tags.yml @@ -0,0 +1,4 @@ +--- +title: Display error message when deleting tag in web UI fails +merge_request: 9906 +author: diff --git a/spec/features/tags/master_deletes_tag_spec.rb b/spec/features/tags/master_deletes_tag_spec.rb index 0f30f562539..ccfafe6db7d 100644 --- a/spec/features/tags/master_deletes_tag_spec.rb +++ b/spec/features/tags/master_deletes_tag_spec.rb @@ -10,16 +10,12 @@ feature 'Master deletes tag', feature: true do visit namespace_project_tags_path(project.namespace, project) end - context 'from the tags list page' do + context 'from the tags list page', js: true do scenario 'deletes the tag' do expect(page).to have_content 'v1.1.0' - page.within('.content') do - first('.btn-remove').click - end + delete_first_tag - expect(current_path).to eq( - namespace_project_tags_path(project.namespace, project)) expect(page).not_to have_content 'v1.1.0' end end @@ -37,4 +33,23 @@ feature 'Master deletes tag', feature: true do expect(page).not_to have_content 'v1.0.0' end end + + context 'when pre-receive hook fails', js: true do + before do + allow_any_instance_of(GitHooksService).to receive(:execute) + .and_raise(GitHooksService::PreReceiveError, 'Do not delete tags') + end + + scenario 'shows the error message' do + delete_first_tag + + expect(page).to have_content('Do not delete tags') + end + end + + def delete_first_tag + page.within('.content') do + first('.btn-remove').click + end + end end -- cgit v1.2.1 From 7849683766e93cfd91e0c864f3deb08500ea35d9 Mon Sep 17 00:00:00 2001 From: Christopher Bartz Date: Tue, 7 Mar 2017 18:57:30 +0100 Subject: Do not show LFS object when LFS is disabled Do not display a 404, when a user tries to retrieve the raw content of an LFS file (pointer) if the config option "lfs_enabled" is set to false. Instead, display the LFS pointer file directly. --- app/controllers/projects/raw_controller.rb | 2 +- app/models/blob.rb | 8 ++- app/views/projects/blob/_blob.html.haml | 2 +- changelogs/unreleased/feature-custom-lfs.yml | 4 ++ features/steps/project/source/browse_files.rb | 1 + spec/controllers/projects/raw_controller_spec.rb | 61 ++++++++++++++++------- spec/features/projects/files/browse_files_spec.rb | 14 +++++- spec/models/blob_spec.rb | 25 ++++++---- 8 files changed, 83 insertions(+), 34 deletions(-) create mode 100644 changelogs/unreleased/feature-custom-lfs.yml diff --git a/app/controllers/projects/raw_controller.rb b/app/controllers/projects/raw_controller.rb index 10d24da16d7..c55b37ae0dd 100644 --- a/app/controllers/projects/raw_controller.rb +++ b/app/controllers/projects/raw_controller.rb @@ -15,7 +15,7 @@ class Projects::RawController < Projects::ApplicationController return if cached_blob? - if @blob.lfs_pointer? + if @blob.lfs_pointer? && project.lfs_enabled? send_lfs_object else send_git_blob @repository, @blob diff --git a/app/models/blob.rb b/app/models/blob.rb index ab92e820335..1376b86fdad 100644 --- a/app/models/blob.rb +++ b/app/models/blob.rb @@ -54,9 +54,13 @@ class Blob < SimpleDelegator UploaderHelper::VIDEO_EXT.include?(extname.downcase.delete('.')) end - def to_partial_path + def to_partial_path(project) if lfs_pointer? - 'download' + if project.lfs_enabled? + 'download' + else + 'text' + end elsif image? || svg? 'image' elsif text? diff --git a/app/views/projects/blob/_blob.html.haml b/app/views/projects/blob/_blob.html.haml index 24ff74ecb3b..bf8801bb1e3 100644 --- a/app/views/projects/blob/_blob.html.haml +++ b/app/views/projects/blob/_blob.html.haml @@ -33,4 +33,4 @@ = number_to_human_size(blob_size(blob)) .file-actions.hidden-xs = render "actions" - = render blob, blob: blob + = render blob.to_partial_path(@project), blob: blob diff --git a/changelogs/unreleased/feature-custom-lfs.yml b/changelogs/unreleased/feature-custom-lfs.yml new file mode 100644 index 00000000000..ec968386a6f --- /dev/null +++ b/changelogs/unreleased/feature-custom-lfs.yml @@ -0,0 +1,4 @@ +--- +title: Do not show LFS object when LFS is disabled +merge_request: 9779 +author: Christopher Bartz diff --git a/features/steps/project/source/browse_files.rb b/features/steps/project/source/browse_files.rb index e84cd9193da..6845f75f22f 100644 --- a/features/steps/project/source/browse_files.rb +++ b/features/steps/project/source/browse_files.rb @@ -337,6 +337,7 @@ class Spinach::Features::ProjectSourceBrowseFiles < Spinach::FeatureSteps end step 'I click on "files/lfs/lfs_object.iso" file in repo' do + allow_any_instance_of(Project).to receive(:lfs_enabled?).and_return(true) visit namespace_project_tree_path(@project.namespace, @project, "lfs") click_link 'files' click_link "lfs" diff --git a/spec/controllers/projects/raw_controller_spec.rb b/spec/controllers/projects/raw_controller_spec.rb index 4cebe3884bf..952071af57f 100644 --- a/spec/controllers/projects/raw_controller_spec.rb +++ b/spec/controllers/projects/raw_controller_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' describe Projects::RawController do let(:public_project) { create(:project, :public, :repository) } - describe "#show" do + describe '#show' do context 'regular filename' do let(:id) { 'master/README.md' } @@ -16,8 +16,8 @@ describe Projects::RawController do expect(response).to have_http_status(200) expect(response.header['Content-Type']).to eq('text/plain; charset=utf-8') expect(response.header['Content-Disposition']). - to eq("inline") - expect(response.header[Gitlab::Workhorse::SEND_DATA_HEADER]).to start_with("git-blob:") + to eq('inline') + expect(response.header[Gitlab::Workhorse::SEND_DATA_HEADER]).to start_with('git-blob:') end end @@ -32,7 +32,7 @@ describe Projects::RawController do expect(response).to have_http_status(200) expect(response.header['Content-Type']).to eq('image/jpeg') - expect(response.header[Gitlab::Workhorse::SEND_DATA_HEADER]).to start_with("git-blob:") + expect(response.header[Gitlab::Workhorse::SEND_DATA_HEADER]).to start_with('git-blob:') end end @@ -40,32 +40,57 @@ describe Projects::RawController do let(:id) { 'be93687/files/lfs/lfs_object.iso' } let!(:lfs_object) { create(:lfs_object, oid: '91eff75a492a3ed0dfcb544d7f31326bc4014c8551849c192fd1e48d4dd2c897', size: '1575078') } - context 'when project has access' do + context 'when lfs is enabled' do before do - public_project.lfs_objects << lfs_object - allow_any_instance_of(LfsObjectUploader).to receive(:exists?).and_return(true) - allow(controller).to receive(:send_file) { controller.head :ok } + allow_any_instance_of(Project).to receive(:lfs_enabled?).and_return(true) end - it 'serves the file' do - expect(controller).to receive(:send_file).with("#{Gitlab.config.shared.path}/lfs-objects/91/ef/f75a492a3ed0dfcb544d7f31326bc4014c8551849c192fd1e48d4dd2c897", filename: "lfs_object.iso", disposition: 'attachment') - get(:show, - namespace_id: public_project.namespace.to_param, - project_id: public_project, - id: id) + context 'when project has access' do + before do + public_project.lfs_objects << lfs_object + allow_any_instance_of(LfsObjectUploader).to receive(:exists?).and_return(true) + allow(controller).to receive(:send_file) { controller.head :ok } + end - expect(response).to have_http_status(200) + it 'serves the file' do + expect(controller).to receive(:send_file).with("#{Gitlab.config.shared.path}/lfs-objects/91/ef/f75a492a3ed0dfcb544d7f31326bc4014c8551849c192fd1e48d4dd2c897", filename: 'lfs_object.iso', disposition: 'attachment') + get(:show, + namespace_id: public_project.namespace.to_param, + project_id: public_project, + id: id) + + expect(response).to have_http_status(200) + end + end + + context 'when project does not have access' do + it 'does not serve the file' do + get(:show, + namespace_id: public_project.namespace.to_param, + project_id: public_project, + id: id) + + expect(response).to have_http_status(404) + end end end - context 'when project does not have access' do - it 'does not serve the file' do + context 'when lfs is not enabled' do + before do + allow_any_instance_of(Project).to receive(:lfs_enabled?).and_return(false) + end + + it 'delivers ASCII file' do get(:show, namespace_id: public_project.namespace.to_param, project_id: public_project, id: id) - expect(response).to have_http_status(404) + expect(response).to have_http_status(200) + expect(response.header['Content-Type']).to eq('text/plain; charset=utf-8') + expect(response.header['Content-Disposition']). + to eq('inline') + expect(response.header[Gitlab::Workhorse::SEND_DATA_HEADER]).to start_with('git-blob:') end end end diff --git a/spec/features/projects/files/browse_files_spec.rb b/spec/features/projects/files/browse_files_spec.rb index 69295e450d0..d281043caa3 100644 --- a/spec/features/projects/files/browse_files_spec.rb +++ b/spec/features/projects/files/browse_files_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -feature 'user checks git blame', feature: true do +feature 'user browses project', feature: true do let(:project) { create(:project) } let(:user) { create(:user) } @@ -18,4 +18,16 @@ feature 'user checks git blame', feature: true do expect(page).to have_content "Dmitriy Zaporozhets" expect(page).to have_content "Initial commit" end + + scenario 'can see raw content of LFS pointer with LFS disabled' do + allow_any_instance_of(Project).to receive(:lfs_enabled?).and_return(false) + click_link 'files' + click_link 'lfs' + click_link 'lfs_object.iso' + + expect(page).not_to have_content 'Download (1.5 MB)' + expect(page).to have_content 'version https://git-lfs.github.com/spec/v1' + expect(page).to have_content 'oid sha256:91eff75a492a3ed0dfcb544d7f31326bc4014c8551849c192fd1e48d4dd2c897' + expect(page).to have_content 'size 1575078' + end end diff --git a/spec/models/blob_spec.rb b/spec/models/blob_spec.rb index 03d02b4d382..94c25a454aa 100644 --- a/spec/models/blob_spec.rb +++ b/spec/models/blob_spec.rb @@ -70,6 +70,8 @@ describe Blob do end describe '#to_partial_path' do + let(:project) { double(lfs_enabled?: true) } + def stubbed_blob(overrides = {}) overrides.reverse_merge!( image?: false, @@ -84,34 +86,35 @@ describe Blob do end end - it 'handles LFS pointers' do - blob = stubbed_blob(lfs_pointer?: true) + it 'handles LFS pointers with LFS enabled' do + blob = stubbed_blob(lfs_pointer?: true, text?: true) + expect(blob.to_partial_path(project)).to eq 'download' + end - expect(blob.to_partial_path).to eq 'download' + it 'handles LFS pointers with LFS disabled' do + blob = stubbed_blob(lfs_pointer?: true, text?: true) + project = double(lfs_enabled?: false) + expect(blob.to_partial_path(project)).to eq 'text' end it 'handles SVGs' do blob = stubbed_blob(text?: true, svg?: true) - - expect(blob.to_partial_path).to eq 'image' + expect(blob.to_partial_path(project)).to eq 'image' end it 'handles images' do blob = stubbed_blob(image?: true) - - expect(blob.to_partial_path).to eq 'image' + expect(blob.to_partial_path(project)).to eq 'image' end it 'handles text' do blob = stubbed_blob(text?: true) - - expect(blob.to_partial_path).to eq 'text' + expect(blob.to_partial_path(project)).to eq 'text' end it 'defaults to download' do blob = stubbed_blob - - expect(blob.to_partial_path).to eq 'download' + expect(blob.to_partial_path(project)).to eq 'download' end end -- cgit v1.2.1 From 67729cecc12a56591160d04ea5d79614b1102dc6 Mon Sep 17 00:00:00 2001 From: Lin Jen-Shin Date: Mon, 13 Mar 2017 20:36:17 +0800 Subject: Add a test which would rollback db and migrate again Closes #29106 --- .gitlab-ci.yml | 7 +++++++ db/migrate/20160919145149_add_group_id_to_labels.rb | 2 +- .../20161020083353_add_pipeline_id_to_merge_request_metrics.rb | 2 +- db/migrate/20161031171301_add_project_id_to_subscriptions.rb | 1 + .../20161207231621_create_environment_name_unique_index.rb | 4 ++-- .../20161209153400_add_unique_index_for_environment_slug.rb | 2 +- db/migrate/20170124174637_add_foreign_keys_to_timelogs.rb | 10 ++++++++++ .../20170216141440_drop_index_for_builds_project_status.rb | 2 +- .../20170206101007_remove_trackable_columns_from_timelogs.rb | 3 ++- 9 files changed, 26 insertions(+), 7 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index deeb01f9a3c..b1ca61604d5 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -222,6 +222,13 @@ rake db:migrate:reset: script: - bundle exec rake db:migrate:reset +rake db:rollback: + stage: test + <<: *use-db + <<: *dedicated-runner + script: + - bundle exec rake db:rollback db:migrate STEP=120 + rake db:seed_fu: stage: test <<: *use-db diff --git a/db/migrate/20160919145149_add_group_id_to_labels.rb b/db/migrate/20160919145149_add_group_id_to_labels.rb index 828b6afddb1..e20e693f3aa 100644 --- a/db/migrate/20160919145149_add_group_id_to_labels.rb +++ b/db/migrate/20160919145149_add_group_id_to_labels.rb @@ -12,8 +12,8 @@ class AddGroupIdToLabels < ActiveRecord::Migration end def down + remove_foreign_key :labels, column: :group_id remove_index :labels, :group_id if index_exists? :labels, :group_id - remove_foreign_key :labels, :namespaces, column: :group_id remove_column :labels, :group_id end end diff --git a/db/migrate/20161020083353_add_pipeline_id_to_merge_request_metrics.rb b/db/migrate/20161020083353_add_pipeline_id_to_merge_request_metrics.rb index ad3eb4a26f9..35ad22b6c01 100644 --- a/db/migrate/20161020083353_add_pipeline_id_to_merge_request_metrics.rb +++ b/db/migrate/20161020083353_add_pipeline_id_to_merge_request_metrics.rb @@ -32,8 +32,8 @@ class AddPipelineIdToMergeRequestMetrics < ActiveRecord::Migration end def down + remove_foreign_key :merge_request_metrics, column: :pipeline_id remove_index :merge_request_metrics, :pipeline_id if index_exists? :merge_request_metrics, :pipeline_id - remove_foreign_key :merge_request_metrics, :ci_commits, column: :pipeline_id remove_column :merge_request_metrics, :pipeline_id end end diff --git a/db/migrate/20161031171301_add_project_id_to_subscriptions.rb b/db/migrate/20161031171301_add_project_id_to_subscriptions.rb index d5c343dc527..8b1c10a124f 100644 --- a/db/migrate/20161031171301_add_project_id_to_subscriptions.rb +++ b/db/migrate/20161031171301_add_project_id_to_subscriptions.rb @@ -9,6 +9,7 @@ class AddProjectIdToSubscriptions < ActiveRecord::Migration end def down + remove_foreign_key :subscriptions, column: :project_id remove_column :subscriptions, :project_id end end diff --git a/db/migrate/20161207231621_create_environment_name_unique_index.rb b/db/migrate/20161207231621_create_environment_name_unique_index.rb index ac680c8d10f..5ff0f5bae4d 100644 --- a/db/migrate/20161207231621_create_environment_name_unique_index.rb +++ b/db/migrate/20161207231621_create_environment_name_unique_index.rb @@ -12,7 +12,7 @@ class CreateEnvironmentNameUniqueIndex < ActiveRecord::Migration end def down - remove_index :environments, [:project_id, :name], unique: true - add_concurrent_index :environments, [:project_id, :name] + remove_index :environments, [:project_id, :name] + add_concurrent_index :environments, [:project_id, :name], unique: true end end diff --git a/db/migrate/20161209153400_add_unique_index_for_environment_slug.rb b/db/migrate/20161209153400_add_unique_index_for_environment_slug.rb index d7ef1aa83d9..ede0316e860 100644 --- a/db/migrate/20161209153400_add_unique_index_for_environment_slug.rb +++ b/db/migrate/20161209153400_add_unique_index_for_environment_slug.rb @@ -14,6 +14,6 @@ class AddUniqueIndexForEnvironmentSlug < ActiveRecord::Migration end def down - remove_index :environments, [:project_id, :slug], unique: true if index_exists? :environments, [:project_id, :slug] + remove_index :environments, [:project_id, :slug] if index_exists? :environments, [:project_id, :slug] end end diff --git a/db/migrate/20170124174637_add_foreign_keys_to_timelogs.rb b/db/migrate/20170124174637_add_foreign_keys_to_timelogs.rb index 69bfa2d3fc4..676e18cddd3 100644 --- a/db/migrate/20170124174637_add_foreign_keys_to_timelogs.rb +++ b/db/migrate/20170124174637_add_foreign_keys_to_timelogs.rb @@ -49,6 +49,16 @@ class AddForeignKeysToTimelogs < ActiveRecord::Migration Timelog.where('issue_id IS NOT NULL').update_all("trackable_id = issue_id, trackable_type = 'Issue'") Timelog.where('merge_request_id IS NOT NULL').update_all("trackable_id = merge_request_id, trackable_type = 'MergeRequest'") + constraint = + if Gitlab::Database.postgresql? + 'CONSTRAINT' + else + 'FOREIGN KEY' + end + + execute "ALTER TABLE timelogs DROP #{constraint} fk_timelogs_issues_issue_id" + execute "ALTER TABLE timelogs DROP #{constraint} fk_timelogs_merge_requests_merge_request_id" + remove_columns :timelogs, :issue_id, :merge_request_id end end diff --git a/db/migrate/20170216141440_drop_index_for_builds_project_status.rb b/db/migrate/20170216141440_drop_index_for_builds_project_status.rb index 906711b9f3f..a2839f52d89 100644 --- a/db/migrate/20170216141440_drop_index_for_builds_project_status.rb +++ b/db/migrate/20170216141440_drop_index_for_builds_project_status.rb @@ -3,6 +3,6 @@ class DropIndexForBuildsProjectStatus < ActiveRecord::Migration DOWNTIME = false def change - remove_index(:ci_commits, [:gl_project_id, :status]) + remove_index(:ci_commits, column: [:gl_project_id, :status]) end end diff --git a/db/post_migrate/20170206101007_remove_trackable_columns_from_timelogs.rb b/db/post_migrate/20170206101007_remove_trackable_columns_from_timelogs.rb index 89aa753646c..aee0c1b6245 100644 --- a/db/post_migrate/20170206101007_remove_trackable_columns_from_timelogs.rb +++ b/db/post_migrate/20170206101007_remove_trackable_columns_from_timelogs.rb @@ -18,6 +18,7 @@ class RemoveTrackableColumnsFromTimelogs < ActiveRecord::Migration # disable_ddl_transaction! def change - remove_columns :timelogs, :trackable_id, :trackable_type + remove_column :timelogs, :trackable_id, :integer + remove_column :timelogs, :trackable_type, :string end end -- cgit v1.2.1 From a6ea8ea568fd1dda495293903578666db2455f37 Mon Sep 17 00:00:00 2001 From: Robert Speicher Date: Thu, 2 Feb 2017 15:19:39 -0500 Subject: Expand the changelog documentation Adds examples of what should and should not get a changelog entry, and examples of good and bad entries. [ci skip] --- doc/development/changelog.md | 89 ++++++++++++++++++++++++++++++++------------ 1 file changed, 65 insertions(+), 24 deletions(-) diff --git a/doc/development/changelog.md b/doc/development/changelog.md index c71858c6a24..ff9a4fc4fec 100644 --- a/doc/development/changelog.md +++ b/doc/development/changelog.md @@ -1,7 +1,7 @@ -# Generate a changelog entry +# Changelog entries -This guide contains instructions for generating a changelog entry data file, as -well as information and history about our changelog process. +This guide contains instructions for when and how to generate a changelog entry +file, as well as information and history about our changelog process. ## Overview @@ -19,19 +19,51 @@ author: Ozzy Osbourne The `merge_request` value is a reference to a merge request that adds this entry, and the `author` key is used to give attribution to community -contributors. Both are optional. +contributors. **Both are optional**. Community contributors and core team members are encouraged to add their name to -the `author` field. GitLab team members should not. - -If you're working on the GitLab EE repository, the entry will be added to -`changelogs/unreleased-ee/` instead. +the `author` field. GitLab team members **should not**. [changelog.md]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CHANGELOG.md [unreleased]: https://gitlab.com/gitlab-org/gitlab-ce/tree/master/changelogs/ [YAML]: https://en.wikipedia.org/wiki/YAML -## Instructions +## What warrants a changelog entry? + +- Any user-facing change **should** have a changelog entry. Example: "GitLab now + uses system fonts for all text." +- A fix for a regression introduced and then fixed in the same release (i.e., + fixing a bug introduced during a monthly release candidate) **should not** + have a changelog entry. +- Any developer-facing change (e.g., refactoring, technical debt remediation, + test suite changes) **should not** have a changelog entry. Example: "Reduce + database records created during Cycle Analytics model spec." +- _Any_ contribution from a community member, no matter how small, **may** have + a changelog entry regardless of these guidelines if the contributor wants one. + Example: "Fixed a typo on the search results page. (Jane Smith)" + +## Writing good changelog entries + +A good changelog entry should be descriptive and concise. It should explain the +change to a reader who has _zero context_ about the change. If you have trouble +making it both concise and descriptive, err on the side of descriptive. + +- **Bad:** Go to a project order. +- **Good:** Show a user's starred projects at the top of the "Go to project" + dropdown. +- **Bad:** Copy [some text] to clipboard. +- **Good:** Update the "Copy to clipboard" tooltip to indicate what's being + copied. +- **Bad:** Fixes and Improves CSS and HTML problems in mini pipeline graph and + builds dropdown. +- **Good:** Fix tooltips and hover states in mini pipeline graph and builds + dropdown. + +Use your best judgement and try to put yourself in the mindset of someone +reading the compiled changelog. Does this entry add value? Does it offer context +about _where_ and _why_ the change was made? + +## How to generate a changelog entry A `bin/changelog` script is available to generate the changelog entry file automatically. @@ -55,19 +87,28 @@ title: Hey DZ, I added a feature to GitLab! merge_request: author: ``` +If you're working on the GitLab EE repository, the entry will be added to +`changelogs/unreleased-ee/` instead. + +#### Arguments -### Arguments +| Argument | Shorthand | Purpose | +| ----------------- | --------- | --------------------------------------------- | +| [`--amend`] | | Amend the previous commit | +| [`--force`] | `-f` | Overwrite an existing entry | +| [`--merge-request`] | `-m` | Set merge request ID | +| [`--dry-run`] | `-n` | Don't actually write anything, just print | +| [`--git-username`] | `-u` | Use Git user.name configuration as the author | +| [`--help`] | `-h` | Print help message | -| Argument | Shorthand | Purpose | -| ----------------- | --------- | --------------------------------------------- | -| `--amend` | | Amend the previous commit | -| `--force` | `-f` | Overwrite an existing entry | -| `--merge-request` | `-m` | Merge Request ID | -| `--dry-run` | `-n` | Don't actually write anything, just print | -| `--git-username` | `-u` | Use Git user.name configuration as the author | -| `--help` | `-h` | Print help message | +[`--amend`]: #-amend +[`--force`]: #-force-or-f +[`--merge-request`]: #-merge-request-or-m +[`--dry-run`]: #-dry-run-or-n +[`--git-username`]: #-git-username-or-u +[`--help`]: #-help -#### `--amend` +##### `--amend` You can pass the **`--amend`** argument to automatically stage the generated file and amend it to the previous commit. @@ -88,7 +129,7 @@ merge_request: author: ``` -#### `--force` or `-f` +##### `--force` or `-f` Use **`--force`** or **`-f`** to overwrite an existing changelog entry if it already exists. @@ -105,7 +146,7 @@ merge_request: 1983 author: ``` -#### `--merge-request` or `-m` +##### `--merge-request` or `-m` Use the **`--merge-request`** or **`-m`** argument to provide the `merge_request` value: @@ -119,7 +160,7 @@ merge_request: 1983 author: ``` -#### `--dry-run` or `-n` +##### `--dry-run` or `-n` Use the **`--dry-run`** or **`-n`** argument to prevent actually writing or committing anything: @@ -135,7 +176,7 @@ author: $ ls changelogs/unreleased/ ``` -#### `--git-username` or `-u` +##### `--git-username` or `-u` Use the **`--git-username`** or **`-u`** argument to automatically fill in the `author` value with your configured Git `user.name` value: @@ -152,7 +193,7 @@ merge_request: author: Jane Doe ``` -## History and Reasoning +### History and Reasoning Our `CHANGELOG` file was previously updated manually by each contributor that felt their change warranted an entry. When two merge requests added their own -- cgit v1.2.1 From 4dc99f326abcd73ba3564e8294c823d0a07a2f44 Mon Sep 17 00:00:00 2001 From: James Edwards-Jones Date: Thu, 9 Mar 2017 14:54:26 +0000 Subject: Adds docs for QueryRecorder tests --- .../merge_request_performance_guidelines.md | 4 ++- doc/development/performance.md | 1 + doc/development/profiling.md | 2 ++ doc/development/query_recorder.md | 29 ++++++++++++++++++++++ 4 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 doc/development/query_recorder.md diff --git a/doc/development/merge_request_performance_guidelines.md b/doc/development/merge_request_performance_guidelines.md index 8232a0a113c..2b4126b43ef 100644 --- a/doc/development/merge_request_performance_guidelines.md +++ b/doc/development/merge_request_performance_guidelines.md @@ -68,7 +68,7 @@ end This will end up running one query for every object to update. This code can easily overload a database given enough rows to update or many instances of this code running in parallel. This particular problem is known as the -["N+1 query problem"](http://guides.rubyonrails.org/active_record_querying.html#eager-loading-associations). +["N+1 query problem"](http://guides.rubyonrails.org/active_record_querying.html#eager-loading-associations). You can write a test with [QueryRecoder](query_recorder.md) to detect this and prevent regressions. In this particular case the workaround is fairly easy: @@ -117,6 +117,8 @@ Post.all.includes(:author).each do |post| end ``` +Also consider using [QueryRecoder tests](query_recorder.md) to prevent a regression when eager loading. + ## Memory Usage **Summary:** merge requests **must not** increase memory usage unless absolutely diff --git a/doc/development/performance.md b/doc/development/performance.md index c1f129e576c..04419650b12 100644 --- a/doc/development/performance.md +++ b/doc/development/performance.md @@ -39,6 +39,7 @@ GitLab provides built-in tools to aid the process of improving performance: * [Sherlock](profiling.md#sherlock) * [GitLab Performance Monitoring](../administration/monitoring/performance/introduction.md) * [Request Profiling](../administration/monitoring/performance/request_profiling.md) +* [QueryRecoder](query_recorder.md) for preventing `N+1` regressions GitLab employees can use GitLab.com's performance monitoring systems located at , this requires you to log in using your diff --git a/doc/development/profiling.md b/doc/development/profiling.md index e244ad4e881..933033a09e0 100644 --- a/doc/development/profiling.md +++ b/doc/development/profiling.md @@ -25,3 +25,5 @@ starting GitLab. For example: Bullet will log query problems to both the Rails log as well as the Chrome console. + +As a follow up to finding `N+1` queries with Bullet, consider writing a [QueryRecoder test](query_recorder.md) to prevent a regression. diff --git a/doc/development/query_recorder.md b/doc/development/query_recorder.md new file mode 100644 index 00000000000..e0127aaed4c --- /dev/null +++ b/doc/development/query_recorder.md @@ -0,0 +1,29 @@ +# QueryRecorder + +QueryRecorder is a tool for detecting the [N+1 queries problem](http://guides.rubyonrails.org/active_record_querying.html#eager-loading-associations) from tests. + +> Implemented in [spec/support/query_recorder.rb](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/spec/support/query_recorder.rb) via [9c623e3e](https://gitlab.com/gitlab-org/gitlab-ce/commit/9c623e3e5d7434f2e30f7c389d13e5af4ede770a) + +As a rule, merge requests [should not increase query counts](merge_request_performance_guidelines.md#query-counts). If you find yourself adding something like `.includes(:author, :assignee)` to avoid having `N+1` queries, consider using QueryRecorder to enforce this with a test. Without this, a new feature which causes an additional model to be accessed will silently reintroduce the problem. + +## How it works + +This style of test works by counting the number of SQL queries executed by ActiveRecord. First a control count is taken, then you add new records to the database and rerun the count. If the number of queries has significantly increased then an `N+1` queries problem exists. + +```ruby +it "avoids N+1 database queries" do + control_count = ActiveRecord::QueryRecorder.new { visit_some_page }.count + create_list(:issue, 5) + expect { visit_some_page }.not_to exceed_query_limit(control_count) +end +``` + +As an example you might create 5 issues in between counts, which would cause the query count to increase by 5 if an N+1 problem exists. + +> **Note:** In some cases the query count might change slightly between runs for unrelated reasons. In this case you might need to test `exceed_query_limit(control_count + acceptable_change)`, but this should be avoided if possible. + +## See also + +- [Bullet](profiling.md#Bullet) For finding `N+1` query problems +- [Performance guidelines](performance.md) +- [Merge request performance guidelines](merge_request_performance_guidelines.md#query-counts) \ No newline at end of file -- cgit v1.2.1 From 8796d17a2670888d8fb975e0b327ecaa362c6f52 Mon Sep 17 00:00:00 2001 From: Mike Greiling Date: Sat, 11 Mar 2017 01:30:44 -0600 Subject: remove IIFEs in preparation for ES module refactor --- app/assets/javascripts/abuse_reports.js | 63 +- app/assets/javascripts/activities.js | 51 +- app/assets/javascripts/admin.js | 120 +- app/assets/javascripts/api.js | 290 ++-- app/assets/javascripts/aside.js | 45 +- app/assets/javascripts/autosave.js | 109 +- app/assets/javascripts/breakpoints.js | 108 +- app/assets/javascripts/broadcast_message.js | 61 +- app/assets/javascripts/build.js | 530 ++++--- app/assets/javascripts/build_artifacts.js | 43 +- app/assets/javascripts/ci_lint_editor.js | 27 +- app/assets/javascripts/commit.js | 18 +- app/assets/javascripts/commits.js | 118 +- app/assets/javascripts/compare.js | 165 ++- app/assets/javascripts/compare_autocomplete.js | 120 +- app/assets/javascripts/confirm_danger_modal.js | 57 +- app/assets/javascripts/copy_as_gfm.js | 637 +++++---- app/assets/javascripts/copy_to_clipboard.js | 93 +- app/assets/javascripts/create_label.js | 207 ++- app/assets/javascripts/diff.js | 206 ++- app/assets/javascripts/dropzone_input.js | 406 +++--- app/assets/javascripts/due_date_select.js | 337 +++-- app/assets/javascripts/files_comment_button.js | 220 ++- app/assets/javascripts/filterable_list.js | 1 + app/assets/javascripts/flash.js | 73 +- app/assets/javascripts/gfm_auto_complete.js | 718 +++++----- app/assets/javascripts/gl_dropdown.js | 1488 ++++++++++---------- app/assets/javascripts/gl_field_error.js | 288 ++-- app/assets/javascripts/gl_field_errors.js | 69 +- app/assets/javascripts/gl_form.js | 144 +- app/assets/javascripts/group_avatar.js | 35 +- app/assets/javascripts/group_label_subscription.js | 97 +- app/assets/javascripts/groups_select.js | 124 +- app/assets/javascripts/header.js | 15 +- app/assets/javascripts/main.js | 348 +++-- 35 files changed, 3683 insertions(+), 3748 deletions(-) diff --git a/app/assets/javascripts/abuse_reports.js b/app/assets/javascripts/abuse_reports.js index 8a260aae1b1..2934565caec 100644 --- a/app/assets/javascripts/abuse_reports.js +++ b/app/assets/javascripts/abuse_reports.js @@ -1,40 +1,37 @@ -/* eslint-disable no-param-reassign */ +const MAX_MESSAGE_LENGTH = 500; +const MESSAGE_CELL_SELECTOR = '.abuse-reports .message'; -((global) => { - const MAX_MESSAGE_LENGTH = 500; - const MESSAGE_CELL_SELECTOR = '.abuse-reports .message'; - - class AbuseReports { - constructor() { - $(MESSAGE_CELL_SELECTOR).each(this.truncateLongMessage); - $(document) - .off('click', MESSAGE_CELL_SELECTOR) - .on('click', MESSAGE_CELL_SELECTOR, this.toggleMessageTruncation); - } +class AbuseReports { + constructor() { + $(MESSAGE_CELL_SELECTOR).each(this.truncateLongMessage); + $(document) + .off('click', MESSAGE_CELL_SELECTOR) + .on('click', MESSAGE_CELL_SELECTOR, this.toggleMessageTruncation); + } - truncateLongMessage() { - const $messageCellElement = $(this); - const reportMessage = $messageCellElement.text(); - if (reportMessage.length > MAX_MESSAGE_LENGTH) { - $messageCellElement.data('original-message', reportMessage); - $messageCellElement.data('message-truncated', 'true'); - $messageCellElement.text(global.text.truncate(reportMessage, MAX_MESSAGE_LENGTH)); - } + truncateLongMessage() { + const $messageCellElement = $(this); + const reportMessage = $messageCellElement.text(); + if (reportMessage.length > MAX_MESSAGE_LENGTH) { + $messageCellElement.data('original-message', reportMessage); + $messageCellElement.data('message-truncated', 'true'); + $messageCellElement.text(global.text.truncate(reportMessage, MAX_MESSAGE_LENGTH)); } + } - toggleMessageTruncation() { - const $messageCellElement = $(this); - const originalMessage = $messageCellElement.data('original-message'); - if (!originalMessage) return; - if ($messageCellElement.data('message-truncated') === 'true') { - $messageCellElement.data('message-truncated', 'false'); - $messageCellElement.text(originalMessage); - } else { - $messageCellElement.data('message-truncated', 'true'); - $messageCellElement.text(`${originalMessage.substr(0, (MAX_MESSAGE_LENGTH - 3))}...`); - } + toggleMessageTruncation() { + const $messageCellElement = $(this); + const originalMessage = $messageCellElement.data('original-message'); + if (!originalMessage) return; + if ($messageCellElement.data('message-truncated') === 'true') { + $messageCellElement.data('message-truncated', 'false'); + $messageCellElement.text(originalMessage); + } else { + $messageCellElement.data('message-truncated', 'true'); + $messageCellElement.text(`${originalMessage.substr(0, (MAX_MESSAGE_LENGTH - 3))}...`); } } +} - global.AbuseReports = AbuseReports; -})(window.gl || (window.gl = {})); +window.gl = window.gl || {}; +window.gl.AbuseReports = AbuseReports; diff --git a/app/assets/javascripts/activities.js b/app/assets/javascripts/activities.js index 648cb4d5d85..aebda7780e1 100644 --- a/app/assets/javascripts/activities.js +++ b/app/assets/javascripts/activities.js @@ -2,36 +2,35 @@ /* global Pager */ /* global Cookies */ -((global) => { - class Activities { - constructor() { - Pager.init(20, true, false, this.updateTooltips); - $('.event-filter-link').on('click', (e) => { - e.preventDefault(); - this.toggleFilter(e.currentTarget); - this.reloadActivities(); - }); - } +class Activities { + constructor() { + Pager.init(20, true, false, this.updateTooltips); + $('.event-filter-link').on('click', (e) => { + e.preventDefault(); + this.toggleFilter(e.currentTarget); + this.reloadActivities(); + }); + } - updateTooltips() { - gl.utils.localTimeAgo($('.js-timeago', '.content_list')); - } + updateTooltips() { + gl.utils.localTimeAgo($('.js-timeago', '.content_list')); + } - reloadActivities() { - $('.content_list').html(''); - Pager.init(20, true, false, this.updateTooltips); - } + reloadActivities() { + $('.content_list').html(''); + Pager.init(20, true, false, this.updateTooltips); + } - toggleFilter(sender) { - const $sender = $(sender); - const filter = $sender.attr('id').split('_')[0]; + toggleFilter(sender) { + const $sender = $(sender); + const filter = $sender.attr('id').split('_')[0]; - $('.event-filter .active').removeClass('active'); - Cookies.set('event_filter', filter); + $('.event-filter .active').removeClass('active'); + Cookies.set('event_filter', filter); - $sender.closest('li').toggleClass('active'); - } + $sender.closest('li').toggleClass('active'); } +} - global.Activities = Activities; -})(window.gl || (window.gl = {})); +window.gl = window.gl || {}; +window.gl.Activities = Activities; diff --git a/app/assets/javascripts/admin.js b/app/assets/javascripts/admin.js index aaed74d6073..34669dd13d6 100644 --- a/app/assets/javascripts/admin.js +++ b/app/assets/javascripts/admin.js @@ -1,64 +1,62 @@ /* eslint-disable func-names, space-before-function-paren, wrap-iife, one-var, no-var, one-var-declaration-per-line, no-unused-vars, no-else-return, prefer-arrow-callback, camelcase, quotes, comma-dangle, max-len */ -(function() { - this.Admin = (function() { - function Admin() { - var modal, showBlacklistType; - $('input#user_force_random_password').on('change', function(elem) { - var elems; - elems = $('#user_password, #user_password_confirmation'); - if ($(this).attr('checked')) { - return elems.val('').attr('disabled', true); - } else { - return elems.removeAttr('disabled'); - } - }); - $('body').on('click', '.js-toggle-colors-link', function(e) { - e.preventDefault(); - return $('.js-toggle-colors-container').toggle(); - }); - $('.log-tabs a').click(function(e) { - e.preventDefault(); - return $(this).tab('show'); - }); - $('.log-bottom').click(function(e) { - var visible_log; - e.preventDefault(); - visible_log = $(".file-content:visible"); - return visible_log.animate({ - scrollTop: visible_log.find('ol').height() - }, "fast"); - }); - modal = $('.change-owner-holder'); - $('.change-owner-link').bind("click", function(e) { - e.preventDefault(); - $(this).hide(); - return modal.show(); - }); - $('.change-owner-cancel-link').bind("click", function(e) { - e.preventDefault(); - modal.hide(); - return $('.change-owner-link').show(); - }); - $('li.project_member').bind('ajax:success', function() { - return gl.utils.refreshCurrentPage(); - }); - $('li.group_member').bind('ajax:success', function() { - return gl.utils.refreshCurrentPage(); - }); - showBlacklistType = function() { - if ($("input[name='blacklist_type']:checked").val() === 'file') { - $('.blacklist-file').show(); - return $('.blacklist-raw').hide(); - } else { - $('.blacklist-file').hide(); - return $('.blacklist-raw').show(); - } - }; - $("input[name='blacklist_type']").click(showBlacklistType); - showBlacklistType(); - } +window.Admin = (function() { + function Admin() { + var modal, showBlacklistType; + $('input#user_force_random_password').on('change', function(elem) { + var elems; + elems = $('#user_password, #user_password_confirmation'); + if ($(this).attr('checked')) { + return elems.val('').attr('disabled', true); + } else { + return elems.removeAttr('disabled'); + } + }); + $('body').on('click', '.js-toggle-colors-link', function(e) { + e.preventDefault(); + return $('.js-toggle-colors-container').toggle(); + }); + $('.log-tabs a').click(function(e) { + e.preventDefault(); + return $(this).tab('show'); + }); + $('.log-bottom').click(function(e) { + var visible_log; + e.preventDefault(); + visible_log = $(".file-content:visible"); + return visible_log.animate({ + scrollTop: visible_log.find('ol').height() + }, "fast"); + }); + modal = $('.change-owner-holder'); + $('.change-owner-link').bind("click", function(e) { + e.preventDefault(); + $(this).hide(); + return modal.show(); + }); + $('.change-owner-cancel-link').bind("click", function(e) { + e.preventDefault(); + modal.hide(); + return $('.change-owner-link').show(); + }); + $('li.project_member').bind('ajax:success', function() { + return gl.utils.refreshCurrentPage(); + }); + $('li.group_member').bind('ajax:success', function() { + return gl.utils.refreshCurrentPage(); + }); + showBlacklistType = function() { + if ($("input[name='blacklist_type']:checked").val() === 'file') { + $('.blacklist-file').show(); + return $('.blacklist-raw').hide(); + } else { + $('.blacklist-file').hide(); + return $('.blacklist-raw').show(); + } + }; + $("input[name='blacklist_type']").click(showBlacklistType); + showBlacklistType(); + } - return Admin; - })(); -}).call(window); + return Admin; +})(); diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index 86e0ad89431..a0946eb392a 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -1,150 +1,148 @@ /* eslint-disable func-names, space-before-function-paren, quotes, object-shorthand, camelcase, no-var, comma-dangle, prefer-arrow-callback, quote-props, no-param-reassign, max-len */ -(function() { - var Api = { - groupsPath: "/api/:version/groups.json", - groupPath: "/api/:version/groups/:id.json", - namespacesPath: "/api/:version/namespaces.json", - groupProjectsPath: "/api/:version/groups/:id/projects.json", - projectsPath: "/api/:version/projects.json?simple=true", - labelsPath: "/:namespace_path/:project_path/labels", - licensePath: "/api/:version/templates/licenses/:key", - gitignorePath: "/api/:version/templates/gitignores/:key", - gitlabCiYmlPath: "/api/:version/templates/gitlab_ci_ymls/:key", - dockerfilePath: "/api/:version/templates/dockerfiles/:key", - issuableTemplatePath: "/:namespace_path/:project_path/templates/:type/:key", - group: function(group_id, callback) { - var url = Api.buildUrl(Api.groupPath) - .replace(':id', group_id); - return $.ajax({ - url: url, - dataType: "json" - }).done(function(group) { - return callback(group); - }); - }, - // Return groups list. Filtered by query - groups: function(query, options, callback) { - var url = Api.buildUrl(Api.groupsPath); - return $.ajax({ - url: url, - data: $.extend({ - search: query, - per_page: 20 - }, options), - dataType: "json" - }).done(function(groups) { - return callback(groups); - }); - }, - // Return namespaces list. Filtered by query - namespaces: function(query, callback) { - var url = Api.buildUrl(Api.namespacesPath); - return $.ajax({ - url: url, - data: { - search: query, - per_page: 20 - }, - dataType: "json" - }).done(function(namespaces) { - return callback(namespaces); - }); - }, - // Return projects list. Filtered by query - projects: function(query, order, callback) { - var url = Api.buildUrl(Api.projectsPath); - return $.ajax({ - url: url, - data: { - search: query, - order_by: order, - per_page: 20 - }, - dataType: "json" - }).done(function(projects) { - return callback(projects); - }); - }, - newLabel: function(namespace_path, project_path, data, callback) { - var url = Api.buildUrl(Api.labelsPath) - .replace(':namespace_path', namespace_path) - .replace(':project_path', project_path); - return $.ajax({ - url: url, - type: "POST", - data: { 'label': data }, - dataType: "json" - }).done(function(label) { - return callback(label); - }).error(function(message) { - return callback(message.responseJSON); - }); - }, - // Return group projects list. Filtered by query - groupProjects: function(group_id, query, callback) { - var url = Api.buildUrl(Api.groupProjectsPath) - .replace(':id', group_id); - return $.ajax({ - url: url, - data: { - search: query, - per_page: 20 - }, - dataType: "json" - }).done(function(projects) { - return callback(projects); - }); - }, - // Return text for a specific license - licenseText: function(key, data, callback) { - var url = Api.buildUrl(Api.licensePath) - .replace(':key', key); - return $.ajax({ - url: url, - data: data - }).done(function(license) { - return callback(license); - }); - }, - gitignoreText: function(key, callback) { - var url = Api.buildUrl(Api.gitignorePath) - .replace(':key', key); - return $.get(url, function(gitignore) { - return callback(gitignore); - }); - }, - gitlabCiYml: function(key, callback) { - var url = Api.buildUrl(Api.gitlabCiYmlPath) - .replace(':key', key); - return $.get(url, function(file) { - return callback(file); - }); - }, - dockerfileYml: function(key, callback) { - var url = Api.buildUrl(Api.dockerfilePath).replace(':key', key); - $.get(url, callback); - }, - issueTemplate: function(namespacePath, projectPath, key, type, callback) { - var url = Api.buildUrl(Api.issuableTemplatePath) - .replace(':key', key) - .replace(':type', type) - .replace(':project_path', projectPath) - .replace(':namespace_path', namespacePath); - $.ajax({ - url: url, - dataType: 'json' - }).done(function(file) { - callback(null, file); - }).error(callback); - }, - buildUrl: function(url) { - if (gon.relative_url_root != null) { - url = gon.relative_url_root + url; - } - return url.replace(':version', gon.api_version); +var Api = { + groupsPath: "/api/:version/groups.json", + groupPath: "/api/:version/groups/:id.json", + namespacesPath: "/api/:version/namespaces.json", + groupProjectsPath: "/api/:version/groups/:id/projects.json", + projectsPath: "/api/:version/projects.json?simple=true", + labelsPath: "/:namespace_path/:project_path/labels", + licensePath: "/api/:version/templates/licenses/:key", + gitignorePath: "/api/:version/templates/gitignores/:key", + gitlabCiYmlPath: "/api/:version/templates/gitlab_ci_ymls/:key", + dockerfilePath: "/api/:version/templates/dockerfiles/:key", + issuableTemplatePath: "/:namespace_path/:project_path/templates/:type/:key", + group: function(group_id, callback) { + var url = Api.buildUrl(Api.groupPath) + .replace(':id', group_id); + return $.ajax({ + url: url, + dataType: "json" + }).done(function(group) { + return callback(group); + }); + }, + // Return groups list. Filtered by query + groups: function(query, options, callback) { + var url = Api.buildUrl(Api.groupsPath); + return $.ajax({ + url: url, + data: $.extend({ + search: query, + per_page: 20 + }, options), + dataType: "json" + }).done(function(groups) { + return callback(groups); + }); + }, + // Return namespaces list. Filtered by query + namespaces: function(query, callback) { + var url = Api.buildUrl(Api.namespacesPath); + return $.ajax({ + url: url, + data: { + search: query, + per_page: 20 + }, + dataType: "json" + }).done(function(namespaces) { + return callback(namespaces); + }); + }, + // Return projects list. Filtered by query + projects: function(query, order, callback) { + var url = Api.buildUrl(Api.projectsPath); + return $.ajax({ + url: url, + data: { + search: query, + order_by: order, + per_page: 20 + }, + dataType: "json" + }).done(function(projects) { + return callback(projects); + }); + }, + newLabel: function(namespace_path, project_path, data, callback) { + var url = Api.buildUrl(Api.labelsPath) + .replace(':namespace_path', namespace_path) + .replace(':project_path', project_path); + return $.ajax({ + url: url, + type: "POST", + data: { 'label': data }, + dataType: "json" + }).done(function(label) { + return callback(label); + }).error(function(message) { + return callback(message.responseJSON); + }); + }, + // Return group projects list. Filtered by query + groupProjects: function(group_id, query, callback) { + var url = Api.buildUrl(Api.groupProjectsPath) + .replace(':id', group_id); + return $.ajax({ + url: url, + data: { + search: query, + per_page: 20 + }, + dataType: "json" + }).done(function(projects) { + return callback(projects); + }); + }, + // Return text for a specific license + licenseText: function(key, data, callback) { + var url = Api.buildUrl(Api.licensePath) + .replace(':key', key); + return $.ajax({ + url: url, + data: data + }).done(function(license) { + return callback(license); + }); + }, + gitignoreText: function(key, callback) { + var url = Api.buildUrl(Api.gitignorePath) + .replace(':key', key); + return $.get(url, function(gitignore) { + return callback(gitignore); + }); + }, + gitlabCiYml: function(key, callback) { + var url = Api.buildUrl(Api.gitlabCiYmlPath) + .replace(':key', key); + return $.get(url, function(file) { + return callback(file); + }); + }, + dockerfileYml: function(key, callback) { + var url = Api.buildUrl(Api.dockerfilePath).replace(':key', key); + $.get(url, callback); + }, + issueTemplate: function(namespacePath, projectPath, key, type, callback) { + var url = Api.buildUrl(Api.issuableTemplatePath) + .replace(':key', key) + .replace(':type', type) + .replace(':project_path', projectPath) + .replace(':namespace_path', namespacePath); + $.ajax({ + url: url, + dataType: 'json' + }).done(function(file) { + callback(null, file); + }).error(callback); + }, + buildUrl: function(url) { + if (gon.relative_url_root != null) { + url = gon.relative_url_root + url; } - }; + return url.replace(':version', gon.api_version); + } +}; - window.Api = Api; -}).call(window); +window.Api = Api; diff --git a/app/assets/javascripts/aside.js b/app/assets/javascripts/aside.js index 448e6e2cc78..88756884d16 100644 --- a/app/assets/javascripts/aside.js +++ b/app/assets/javascripts/aside.js @@ -1,25 +1,24 @@ /* eslint-disable func-names, space-before-function-paren, wrap-iife, quotes, prefer-arrow-callback, no-var, one-var, one-var-declaration-per-line, no-else-return, max-len */ -(function() { - this.Aside = (function() { - function Aside() { - $(document).off("click", "a.show-aside"); - $(document).on("click", 'a.show-aside', function(e) { - var btn, icon; - e.preventDefault(); - btn = $(e.currentTarget); - icon = btn.find('i'); - if (icon.hasClass('fa-angle-left')) { - btn.parent().find('section').hide(); - btn.parent().find('aside').fadeIn(); - return icon.removeClass('fa-angle-left').addClass('fa-angle-right'); - } else { - btn.parent().find('aside').hide(); - btn.parent().find('section').fadeIn(); - return icon.removeClass('fa-angle-right').addClass('fa-angle-left'); - } - }); - } - return Aside; - })(); -}).call(window); +window.Aside = (function() { + function Aside() { + $(document).off("click", "a.show-aside"); + $(document).on("click", 'a.show-aside', function(e) { + var btn, icon; + e.preventDefault(); + btn = $(e.currentTarget); + icon = btn.find('i'); + if (icon.hasClass('fa-angle-left')) { + btn.parent().find('section').hide(); + btn.parent().find('aside').fadeIn(); + return icon.removeClass('fa-angle-left').addClass('fa-angle-right'); + } else { + btn.parent().find('aside').hide(); + btn.parent().find('section').fadeIn(); + return icon.removeClass('fa-angle-right').addClass('fa-angle-left'); + } + }); + } + + return Aside; +})(); diff --git a/app/assets/javascripts/autosave.js b/app/assets/javascripts/autosave.js index e55405135fb..8630b18a73f 100644 --- a/app/assets/javascripts/autosave.js +++ b/app/assets/javascripts/autosave.js @@ -1,62 +1,61 @@ /* eslint-disable func-names, space-before-function-paren, wrap-iife, no-param-reassign, quotes, prefer-template, no-var, one-var, no-unused-vars, one-var-declaration-per-line, no-void, consistent-return, no-empty, max-len */ -(function() { - this.Autosave = (function() { - function Autosave(field, key) { - this.field = field; - if (key.join != null) { - key = key.join("/"); - } - this.key = "autosave/" + key; - this.field.data("autosave", this); - this.restore(); - this.field.on("input", (function(_this) { - return function() { - return _this.save(); - }; - })(this)); - } - Autosave.prototype.restore = function() { - var e, text; - if (window.localStorage == null) { - return; - } - try { - text = window.localStorage.getItem(this.key); - } catch (error) { - e = error; - return; - } - if ((text != null ? text.length : void 0) > 0) { - this.field.val(text); - } - return this.field.trigger("input"); - }; +window.Autosave = (function() { + function Autosave(field, key) { + this.field = field; + if (key.join != null) { + key = key.join("/"); + } + this.key = "autosave/" + key; + this.field.data("autosave", this); + this.restore(); + this.field.on("input", (function(_this) { + return function() { + return _this.save(); + }; + })(this)); + } - Autosave.prototype.save = function() { - var text; - if (window.localStorage == null) { - return; - } - text = this.field.val(); - if ((text != null ? text.length : void 0) > 0) { - try { - return window.localStorage.setItem(this.key, text); - } catch (error) {} - } else { - return this.reset(); - } - }; + Autosave.prototype.restore = function() { + var e, text; + if (window.localStorage == null) { + return; + } + try { + text = window.localStorage.getItem(this.key); + } catch (error) { + e = error; + return; + } + if ((text != null ? text.length : void 0) > 0) { + this.field.val(text); + } + return this.field.trigger("input"); + }; - Autosave.prototype.reset = function() { - if (window.localStorage == null) { - return; - } + Autosave.prototype.save = function() { + var text; + if (window.localStorage == null) { + return; + } + text = this.field.val(); + if ((text != null ? text.length : void 0) > 0) { try { - return window.localStorage.removeItem(this.key); + return window.localStorage.setItem(this.key, text); } catch (error) {} - }; + } else { + return this.reset(); + } + }; + + Autosave.prototype.reset = function() { + if (window.localStorage == null) { + return; + } + try { + return window.localStorage.removeItem(this.key); + } catch (error) {} + }; - return Autosave; - })(); -}).call(window); + return Autosave; +})(); diff --git a/app/assets/javascripts/breakpoints.js b/app/assets/javascripts/breakpoints.js index 22e93328548..2c1f988d987 100644 --- a/app/assets/javascripts/breakpoints.js +++ b/app/assets/javascripts/breakpoints.js @@ -1,72 +1,66 @@ /* eslint-disable func-names, space-before-function-paren, wrap-iife, one-var, no-var, one-var-declaration-per-line, quotes, no-shadow, prefer-arrow-callback, prefer-template, consistent-return, no-return-assign, new-parens, no-param-reassign, max-len */ -(function() { - var Breakpoints = (function() { - var BreakpointInstance, instance; +var Breakpoints = (function() { + var BreakpointInstance, instance; - function Breakpoints() {} + function Breakpoints() {} - instance = null; + instance = null; - BreakpointInstance = (function() { - var BREAKPOINTS; + BreakpointInstance = (function() { + var BREAKPOINTS; - BREAKPOINTS = ["xs", "sm", "md", "lg"]; + BREAKPOINTS = ["xs", "sm", "md", "lg"]; - function BreakpointInstance() { - this.setup(); - } - - BreakpointInstance.prototype.setup = function() { - var allDeviceSelector, els; - allDeviceSelector = BREAKPOINTS.map(function(breakpoint) { - return ".device-" + breakpoint; - }); - if ($(allDeviceSelector.join(",")).length) { - return; - } - // Create all the elements - els = $.map(BREAKPOINTS, function(breakpoint) { - return "
"; - }); - return $("body").append(els.join('')); - }; + function BreakpointInstance() { + this.setup(); + } - BreakpointInstance.prototype.visibleDevice = function() { - var allDeviceSelector; - allDeviceSelector = BREAKPOINTS.map(function(breakpoint) { - return ".device-" + breakpoint; - }); - return $(allDeviceSelector.join(",")).filter(":visible"); - }; - - BreakpointInstance.prototype.getBreakpointSize = function() { - var $visibleDevice; - $visibleDevice = this.visibleDevice; - // TODO: Consider refactoring in light of turbolinks removal. - // the page refreshed via turbolinks - if (!$visibleDevice().length) { - this.setup(); - } - $visibleDevice = this.visibleDevice(); - return $visibleDevice.attr("class").split("visible-")[1]; - }; + BreakpointInstance.prototype.setup = function() { + var allDeviceSelector, els; + allDeviceSelector = BREAKPOINTS.map(function(breakpoint) { + return ".device-" + breakpoint; + }); + if ($(allDeviceSelector.join(",")).length) { + return; + } + // Create all the elements + els = $.map(BREAKPOINTS, function(breakpoint) { + return "
"; + }); + return $("body").append(els.join('')); + }; - return BreakpointInstance; - })(); + BreakpointInstance.prototype.visibleDevice = function() { + var allDeviceSelector; + allDeviceSelector = BREAKPOINTS.map(function(breakpoint) { + return ".device-" + breakpoint; + }); + return $(allDeviceSelector.join(",")).filter(":visible"); + }; - Breakpoints.get = function() { - return instance != null ? instance : instance = new BreakpointInstance; + BreakpointInstance.prototype.getBreakpointSize = function() { + var $visibleDevice; + $visibleDevice = this.visibleDevice; + // TODO: Consider refactoring in light of turbolinks removal. + // the page refreshed via turbolinks + if (!$visibleDevice().length) { + this.setup(); + } + $visibleDevice = this.visibleDevice(); + return $visibleDevice.attr("class").split("visible-")[1]; }; - return Breakpoints; + return BreakpointInstance; })(); - $((function(_this) { - return function() { - return _this.bp = Breakpoints.get(); - }; - })(this)); + Breakpoints.get = function() { + return instance != null ? instance : instance = new BreakpointInstance; + }; + + return Breakpoints; +})(); + +$(() => { window.bp = Breakpoints.get(); }); - window.Breakpoints = Breakpoints; -}).call(window); +window.Breakpoints = Breakpoints; diff --git a/app/assets/javascripts/broadcast_message.js b/app/assets/javascripts/broadcast_message.js index e8531c43b4b..f73e489e7b2 100644 --- a/app/assets/javascripts/broadcast_message.js +++ b/app/assets/javascripts/broadcast_message.js @@ -1,34 +1,33 @@ /* eslint-disable func-names, space-before-function-paren, prefer-arrow-callback, no-var, quotes, no-else-return, object-shorthand, comma-dangle, max-len */ -(function() { - $(function() { - var previewPath; - $('input#broadcast_message_color').on('input', function() { - var previewColor; - previewColor = $(this).val(); - return $('div.broadcast-message-preview').css('background-color', previewColor); - }); - $('input#broadcast_message_font').on('input', function() { - var previewColor; - previewColor = $(this).val(); - return $('div.broadcast-message-preview').css('color', previewColor); - }); - previewPath = $('textarea#broadcast_message_message').data('preview-path'); - return $('textarea#broadcast_message_message').on('input', function() { - var message; - message = $(this).val(); - if (message === '') { - return $('.js-broadcast-message-preview').text("Your message here"); - } else { - return $.ajax({ - url: previewPath, - type: "POST", - data: { - broadcast_message: { - message: message - } + +$(function() { + var previewPath; + $('input#broadcast_message_color').on('input', function() { + var previewColor; + previewColor = $(this).val(); + return $('div.broadcast-message-preview').css('background-color', previewColor); + }); + $('input#broadcast_message_font').on('input', function() { + var previewColor; + previewColor = $(this).val(); + return $('div.broadcast-message-preview').css('color', previewColor); + }); + previewPath = $('textarea#broadcast_message_message').data('preview-path'); + return $('textarea#broadcast_message_message').on('input', function() { + var message; + message = $(this).val(); + if (message === '') { + return $('.js-broadcast-message-preview').text("Your message here"); + } else { + return $.ajax({ + url: previewPath, + type: "POST", + data: { + broadcast_message: { + message: message } - }); - } - }); + } + }); + } }); -}).call(window); +}); diff --git a/app/assets/javascripts/build.js b/app/assets/javascripts/build.js index 6e6e9b18686..6efd26ccc37 100644 --- a/app/assets/javascripts/build.js +++ b/app/assets/javascripts/build.js @@ -1,285 +1,283 @@ /* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, no-use-before-define, no-param-reassign, quotes, yoda, no-else-return, consistent-return, comma-dangle, object-shorthand, prefer-template, one-var, one-var-declaration-per-line, no-unused-vars, max-len, vars-on-top */ /* global Breakpoints */ -(function() { - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - var AUTO_SCROLL_OFFSET = 75; - var DOWN_BUILD_TRACE = '#down-build-trace'; - - this.Build = (function() { - Build.timeout = null; - - Build.state = null; - - function Build(options) { - options = options || $('.js-build-options').data(); - this.pageUrl = options.pageUrl; - this.buildUrl = options.buildUrl; - this.buildStatus = options.buildStatus; - this.state = options.logState; - this.buildStage = options.buildStage; - this.updateDropdown = bind(this.updateDropdown, this); - this.$document = $(document); - this.$body = $('body'); - this.$buildTrace = $('#build-trace'); - this.$autoScrollContainer = $('.autoscroll-container'); - this.$autoScrollStatus = $('#autoscroll-status'); - this.$autoScrollStatusText = this.$autoScrollStatus.find('.status-text'); - this.$upBuildTrace = $('#up-build-trace'); - this.$downBuildTrace = $(DOWN_BUILD_TRACE); - this.$scrollTopBtn = $('#scroll-top'); - this.$scrollBottomBtn = $('#scroll-bottom'); - this.$buildRefreshAnimation = $('.js-build-refresh'); - - clearTimeout(Build.timeout); - // Init breakpoint checker - this.bp = Breakpoints.get(); - - this.initSidebar(); - this.$buildScroll = $('#js-build-scroll'); - - this.populateJobs(this.buildStage); - this.updateStageDropdownText(this.buildStage); - this.sidebarOnResize(); - - this.$document.off('click', '.js-sidebar-build-toggle').on('click', '.js-sidebar-build-toggle', this.sidebarOnClick.bind(this)); - this.$document.off('click', '.stage-item').on('click', '.stage-item', this.updateDropdown); - this.$document.on('scroll', this.initScrollMonitor.bind(this)); - $(window).off('resize.build').on('resize.build', this.sidebarOnResize.bind(this)); - $('a', this.$buildScroll).off('click.stepTrace').on('click.stepTrace', this.stepTrace); - this.updateArtifactRemoveDate(); - if ($('#build-trace').length) { - this.getInitialBuildTrace(); - this.initScrollButtonAffix(); - } - this.invokeBuildTrace(); +var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; +var AUTO_SCROLL_OFFSET = 75; +var DOWN_BUILD_TRACE = '#down-build-trace'; + +window.Build = (function() { + Build.timeout = null; + + Build.state = null; + + function Build(options) { + options = options || $('.js-build-options').data(); + this.pageUrl = options.pageUrl; + this.buildUrl = options.buildUrl; + this.buildStatus = options.buildStatus; + this.state = options.logState; + this.buildStage = options.buildStage; + this.updateDropdown = bind(this.updateDropdown, this); + this.$document = $(document); + this.$body = $('body'); + this.$buildTrace = $('#build-trace'); + this.$autoScrollContainer = $('.autoscroll-container'); + this.$autoScrollStatus = $('#autoscroll-status'); + this.$autoScrollStatusText = this.$autoScrollStatus.find('.status-text'); + this.$upBuildTrace = $('#up-build-trace'); + this.$downBuildTrace = $(DOWN_BUILD_TRACE); + this.$scrollTopBtn = $('#scroll-top'); + this.$scrollBottomBtn = $('#scroll-bottom'); + this.$buildRefreshAnimation = $('.js-build-refresh'); + + clearTimeout(Build.timeout); + // Init breakpoint checker + this.bp = Breakpoints.get(); + + this.initSidebar(); + this.$buildScroll = $('#js-build-scroll'); + + this.populateJobs(this.buildStage); + this.updateStageDropdownText(this.buildStage); + this.sidebarOnResize(); + + this.$document.off('click', '.js-sidebar-build-toggle').on('click', '.js-sidebar-build-toggle', this.sidebarOnClick.bind(this)); + this.$document.off('click', '.stage-item').on('click', '.stage-item', this.updateDropdown); + this.$document.on('scroll', this.initScrollMonitor.bind(this)); + $(window).off('resize.build').on('resize.build', this.sidebarOnResize.bind(this)); + $('a', this.$buildScroll).off('click.stepTrace').on('click.stepTrace', this.stepTrace); + this.updateArtifactRemoveDate(); + if ($('#build-trace').length) { + this.getInitialBuildTrace(); + this.initScrollButtonAffix(); } - - Build.prototype.initSidebar = function() { - this.$sidebar = $('.js-build-sidebar'); - this.$sidebar.niceScroll(); - this.$document.off('click', '.js-sidebar-build-toggle').on('click', '.js-sidebar-build-toggle', this.toggleSidebar); - }; - - Build.prototype.location = function() { - return window.location.href.split("#")[0]; - }; - - Build.prototype.invokeBuildTrace = function() { - var continueRefreshStatuses = ['running', 'pending']; - // Continue to update build trace when build is running or pending - if (continueRefreshStatuses.indexOf(this.buildStatus) !== -1) { - // Check for new build output if user still watching build page - // Only valid for runnig build when output changes during time - Build.timeout = setTimeout((function(_this) { - return function() { - if (_this.location() === _this.pageUrl) { - return _this.getBuildTrace(); - } - }; - })(this), 4000); - } - }; - - Build.prototype.getInitialBuildTrace = function() { - var removeRefreshStatuses = ['success', 'failed', 'canceled', 'skipped']; - - return $.ajax({ - url: this.buildUrl, - dataType: 'json', - success: function(buildData) { - $('.js-build-output').html(buildData.trace_html); - if (window.location.hash === DOWN_BUILD_TRACE) { - $("html,body").scrollTop(this.$buildTrace.height()); + this.invokeBuildTrace(); + } + + Build.prototype.initSidebar = function() { + this.$sidebar = $('.js-build-sidebar'); + this.$sidebar.niceScroll(); + this.$document.off('click', '.js-sidebar-build-toggle').on('click', '.js-sidebar-build-toggle', this.toggleSidebar); + }; + + Build.prototype.location = function() { + return window.location.href.split("#")[0]; + }; + + Build.prototype.invokeBuildTrace = function() { + var continueRefreshStatuses = ['running', 'pending']; + // Continue to update build trace when build is running or pending + if (continueRefreshStatuses.indexOf(this.buildStatus) !== -1) { + // Check for new build output if user still watching build page + // Only valid for runnig build when output changes during time + Build.timeout = setTimeout((function(_this) { + return function() { + if (_this.location() === _this.pageUrl) { + return _this.getBuildTrace(); } - if (removeRefreshStatuses.indexOf(buildData.status) !== -1) { - this.$buildRefreshAnimation.remove(); - return this.initScrollMonitor(); + }; + })(this), 4000); + } + }; + + Build.prototype.getInitialBuildTrace = function() { + var removeRefreshStatuses = ['success', 'failed', 'canceled', 'skipped']; + + return $.ajax({ + url: this.buildUrl, + dataType: 'json', + success: function(buildData) { + $('.js-build-output').html(buildData.trace_html); + if (window.location.hash === DOWN_BUILD_TRACE) { + $("html,body").scrollTop(this.$buildTrace.height()); + } + if (removeRefreshStatuses.indexOf(buildData.status) !== -1) { + this.$buildRefreshAnimation.remove(); + return this.initScrollMonitor(); + } + }.bind(this) + }); + }; + + Build.prototype.getBuildTrace = function() { + return $.ajax({ + url: this.pageUrl + "/trace.json?state=" + (encodeURIComponent(this.state)), + dataType: "json", + success: (function(_this) { + return function(log) { + var pageUrl; + + if (log.state) { + _this.state = log.state; } - }.bind(this) - }); - }; - - Build.prototype.getBuildTrace = function() { - return $.ajax({ - url: this.pageUrl + "/trace.json?state=" + (encodeURIComponent(this.state)), - dataType: "json", - success: (function(_this) { - return function(log) { - var pageUrl; - - if (log.state) { - _this.state = log.state; + _this.invokeBuildTrace(); + if (log.status === "running") { + if (log.append) { + $('.js-build-output').append(log.html); + } else { + $('.js-build-output').html(log.html); } - _this.invokeBuildTrace(); - if (log.status === "running") { - if (log.append) { - $('.js-build-output').append(log.html); - } else { - $('.js-build-output').html(log.html); - } - return _this.checkAutoscroll(); - } else if (log.status !== _this.buildStatus) { - pageUrl = _this.pageUrl; - if (_this.$autoScrollStatus.data('state') === 'enabled') { - pageUrl += DOWN_BUILD_TRACE; - } - - return gl.utils.visitUrl(pageUrl); + return _this.checkAutoscroll(); + } else if (log.status !== _this.buildStatus) { + pageUrl = _this.pageUrl; + if (_this.$autoScrollStatus.data('state') === 'enabled') { + pageUrl += DOWN_BUILD_TRACE; } - }; - })(this) - }); - }; - - Build.prototype.checkAutoscroll = function() { - if (this.$autoScrollStatus.data("state") === "enabled") { - return $("html,body").scrollTop(this.$buildTrace.height()); - } - - // Handle a situation where user started new build - // but never scrolled a page - if (!this.$scrollTopBtn.is(':visible') && - !this.$scrollBottomBtn.is(':visible') && - !gl.utils.isInViewport(this.$downBuildTrace.get(0))) { - this.$scrollBottomBtn.show(); - } - }; - Build.prototype.initScrollButtonAffix = function() { - // Hide everything initially - this.$scrollTopBtn.hide(); - this.$scrollBottomBtn.hide(); - this.$autoScrollContainer.hide(); - }; - - // Page scroll listener to detect if user has scrolling page - // and handle following cases - // 1) User is at Top of Build Log; - // - Hide Top Arrow button - // - Show Bottom Arrow button - // - Disable Autoscroll and hide indicator (when build is running) - // 2) User is at Bottom of Build Log; - // - Show Top Arrow button - // - Hide Bottom Arrow button - // - Enable Autoscroll and show indicator (when build is running) - // 3) User is somewhere in middle of Build Log; - // - Show Top Arrow button - // - Show Bottom Arrow button - // - Disable Autoscroll and hide indicator (when build is running) - Build.prototype.initScrollMonitor = function() { - if (!gl.utils.isInViewport(this.$upBuildTrace.get(0)) && !gl.utils.isInViewport(this.$downBuildTrace.get(0))) { - // User is somewhere in middle of Build Log - - this.$scrollTopBtn.show(); - - if (this.buildStatus === 'success' || this.buildStatus === 'failed') { // Check if Build is completed - this.$scrollBottomBtn.show(); - } else if (this.$buildRefreshAnimation.is(':visible') && !gl.utils.isInViewport(this.$buildRefreshAnimation.get(0))) { - this.$scrollBottomBtn.show(); - } else { - this.$scrollBottomBtn.hide(); - } - - // Hide Autoscroll Status Indicator - if (this.$scrollBottomBtn.is(':visible')) { - this.$autoScrollContainer.hide(); - this.$autoScrollStatusText.removeClass('animate'); - } else { - this.$autoScrollContainer.css({ top: this.$body.outerHeight() - AUTO_SCROLL_OFFSET }).show(); - this.$autoScrollStatusText.addClass('animate'); - } - } else if (gl.utils.isInViewport(this.$upBuildTrace.get(0)) && !gl.utils.isInViewport(this.$downBuildTrace.get(0))) { - // User is at Top of Build Log + return gl.utils.visitUrl(pageUrl); + } + }; + })(this) + }); + }; + + Build.prototype.checkAutoscroll = function() { + if (this.$autoScrollStatus.data("state") === "enabled") { + return $("html,body").scrollTop(this.$buildTrace.height()); + } - this.$scrollTopBtn.hide(); + // Handle a situation where user started new build + // but never scrolled a page + if (!this.$scrollTopBtn.is(':visible') && + !this.$scrollBottomBtn.is(':visible') && + !gl.utils.isInViewport(this.$downBuildTrace.get(0))) { + this.$scrollBottomBtn.show(); + } + }; + + Build.prototype.initScrollButtonAffix = function() { + // Hide everything initially + this.$scrollTopBtn.hide(); + this.$scrollBottomBtn.hide(); + this.$autoScrollContainer.hide(); + }; + + // Page scroll listener to detect if user has scrolling page + // and handle following cases + // 1) User is at Top of Build Log; + // - Hide Top Arrow button + // - Show Bottom Arrow button + // - Disable Autoscroll and hide indicator (when build is running) + // 2) User is at Bottom of Build Log; + // - Show Top Arrow button + // - Hide Bottom Arrow button + // - Enable Autoscroll and show indicator (when build is running) + // 3) User is somewhere in middle of Build Log; + // - Show Top Arrow button + // - Show Bottom Arrow button + // - Disable Autoscroll and hide indicator (when build is running) + Build.prototype.initScrollMonitor = function() { + if (!gl.utils.isInViewport(this.$upBuildTrace.get(0)) && !gl.utils.isInViewport(this.$downBuildTrace.get(0))) { + // User is somewhere in middle of Build Log + + this.$scrollTopBtn.show(); + + if (this.buildStatus === 'success' || this.buildStatus === 'failed') { // Check if Build is completed + this.$scrollBottomBtn.show(); + } else if (this.$buildRefreshAnimation.is(':visible') && !gl.utils.isInViewport(this.$buildRefreshAnimation.get(0))) { this.$scrollBottomBtn.show(); + } else { + this.$scrollBottomBtn.hide(); + } + // Hide Autoscroll Status Indicator + if (this.$scrollBottomBtn.is(':visible')) { this.$autoScrollContainer.hide(); this.$autoScrollStatusText.removeClass('animate'); - } else if ((!gl.utils.isInViewport(this.$upBuildTrace.get(0)) && gl.utils.isInViewport(this.$downBuildTrace.get(0))) || - (this.$buildRefreshAnimation.is(':visible') && gl.utils.isInViewport(this.$buildRefreshAnimation.get(0)))) { - // User is at Bottom of Build Log - - this.$scrollTopBtn.show(); - this.$scrollBottomBtn.hide(); - - // Show and Reposition Autoscroll Status Indicator + } else { this.$autoScrollContainer.css({ top: this.$body.outerHeight() - AUTO_SCROLL_OFFSET }).show(); this.$autoScrollStatusText.addClass('animate'); - } else if (gl.utils.isInViewport(this.$upBuildTrace.get(0)) && gl.utils.isInViewport(this.$downBuildTrace.get(0))) { - // Build Log height is small + } + } else if (gl.utils.isInViewport(this.$upBuildTrace.get(0)) && !gl.utils.isInViewport(this.$downBuildTrace.get(0))) { + // User is at Top of Build Log - this.$scrollTopBtn.hide(); - this.$scrollBottomBtn.hide(); + this.$scrollTopBtn.hide(); + this.$scrollBottomBtn.show(); - // Hide Autoscroll Status Indicator - this.$autoScrollContainer.hide(); - this.$autoScrollStatusText.removeClass('animate'); - } + this.$autoScrollContainer.hide(); + this.$autoScrollStatusText.removeClass('animate'); + } else if ((!gl.utils.isInViewport(this.$upBuildTrace.get(0)) && gl.utils.isInViewport(this.$downBuildTrace.get(0))) || + (this.$buildRefreshAnimation.is(':visible') && gl.utils.isInViewport(this.$buildRefreshAnimation.get(0)))) { + // User is at Bottom of Build Log - if (this.buildStatus === "running" || this.buildStatus === "pending") { - // Check if Refresh Animation is in Viewport and enable Autoscroll, disable otherwise. - this.$autoScrollStatus.data("state", gl.utils.isInViewport(this.$buildRefreshAnimation.get(0)) ? 'enabled' : 'disabled'); - } - }; - - Build.prototype.shouldHideSidebarForViewport = function() { - var bootstrapBreakpoint; - bootstrapBreakpoint = this.bp.getBreakpointSize(); - return bootstrapBreakpoint === 'xs' || bootstrapBreakpoint === 'sm'; - }; - - Build.prototype.toggleSidebar = function(shouldHide) { - var shouldShow = typeof shouldHide === 'boolean' ? !shouldHide : undefined; - this.$buildScroll.toggleClass('sidebar-expanded', shouldShow) - .toggleClass('sidebar-collapsed', shouldHide); - this.$sidebar.toggleClass('right-sidebar-expanded', shouldShow) - .toggleClass('right-sidebar-collapsed', shouldHide); - }; - - Build.prototype.sidebarOnResize = function() { - this.toggleSidebar(this.shouldHideSidebarForViewport()); - }; - - Build.prototype.sidebarOnClick = function() { - if (this.shouldHideSidebarForViewport()) this.toggleSidebar(); - }; - - Build.prototype.updateArtifactRemoveDate = function() { - var $date, date; - $date = $('.js-artifacts-remove'); - if ($date.length) { - date = $date.text(); - return $date.text(gl.utils.timeFor(new Date(date.replace(/([0-9]+)-([0-9]+)-([0-9]+)/g, '$1/$2/$3')), ' ')); - } - }; - - Build.prototype.populateJobs = function(stage) { - $('.build-job').hide(); - $('.build-job[data-stage="' + stage + '"]').show(); - }; - - Build.prototype.updateStageDropdownText = function(stage) { - $('.stage-selection').text(stage); - }; - - Build.prototype.updateDropdown = function(e) { - e.preventDefault(); - var stage = e.currentTarget.text; - this.updateStageDropdownText(stage); - this.populateJobs(stage); - }; - - Build.prototype.stepTrace = function(e) { - var $currentTarget; - e.preventDefault(); - $currentTarget = $(e.currentTarget); - $.scrollTo($currentTarget.attr('href'), { - offset: 0 - }); - }; - - return Build; - })(); -}).call(window); + this.$scrollTopBtn.show(); + this.$scrollBottomBtn.hide(); + + // Show and Reposition Autoscroll Status Indicator + this.$autoScrollContainer.css({ top: this.$body.outerHeight() - AUTO_SCROLL_OFFSET }).show(); + this.$autoScrollStatusText.addClass('animate'); + } else if (gl.utils.isInViewport(this.$upBuildTrace.get(0)) && gl.utils.isInViewport(this.$downBuildTrace.get(0))) { + // Build Log height is small + + this.$scrollTopBtn.hide(); + this.$scrollBottomBtn.hide(); + + // Hide Autoscroll Status Indicator + this.$autoScrollContainer.hide(); + this.$autoScrollStatusText.removeClass('animate'); + } + + if (this.buildStatus === "running" || this.buildStatus === "pending") { + // Check if Refresh Animation is in Viewport and enable Autoscroll, disable otherwise. + this.$autoScrollStatus.data("state", gl.utils.isInViewport(this.$buildRefreshAnimation.get(0)) ? 'enabled' : 'disabled'); + } + }; + + Build.prototype.shouldHideSidebarForViewport = function() { + var bootstrapBreakpoint; + bootstrapBreakpoint = this.bp.getBreakpointSize(); + return bootstrapBreakpoint === 'xs' || bootstrapBreakpoint === 'sm'; + }; + + Build.prototype.toggleSidebar = function(shouldHide) { + var shouldShow = typeof shouldHide === 'boolean' ? !shouldHide : undefined; + this.$buildScroll.toggleClass('sidebar-expanded', shouldShow) + .toggleClass('sidebar-collapsed', shouldHide); + this.$sidebar.toggleClass('right-sidebar-expanded', shouldShow) + .toggleClass('right-sidebar-collapsed', shouldHide); + }; + + Build.prototype.sidebarOnResize = function() { + this.toggleSidebar(this.shouldHideSidebarForViewport()); + }; + + Build.prototype.sidebarOnClick = function() { + if (this.shouldHideSidebarForViewport()) this.toggleSidebar(); + }; + + Build.prototype.updateArtifactRemoveDate = function() { + var $date, date; + $date = $('.js-artifacts-remove'); + if ($date.length) { + date = $date.text(); + return $date.text(gl.utils.timeFor(new Date(date.replace(/([0-9]+)-([0-9]+)-([0-9]+)/g, '$1/$2/$3')), ' ')); + } + }; + + Build.prototype.populateJobs = function(stage) { + $('.build-job').hide(); + $('.build-job[data-stage="' + stage + '"]').show(); + }; + + Build.prototype.updateStageDropdownText = function(stage) { + $('.stage-selection').text(stage); + }; + + Build.prototype.updateDropdown = function(e) { + e.preventDefault(); + var stage = e.currentTarget.text; + this.updateStageDropdownText(stage); + this.populateJobs(stage); + }; + + Build.prototype.stepTrace = function(e) { + var $currentTarget; + e.preventDefault(); + $currentTarget = $(e.currentTarget); + $.scrollTo($currentTarget.attr('href'), { + offset: 0 + }); + }; + + return Build; +})(); diff --git a/app/assets/javascripts/build_artifacts.js b/app/assets/javascripts/build_artifacts.js index cae9a0ffca4..bd479700fd3 100644 --- a/app/assets/javascripts/build_artifacts.js +++ b/app/assets/javascripts/build_artifacts.js @@ -1,26 +1,25 @@ /* eslint-disable func-names, space-before-function-paren, wrap-iife, prefer-arrow-callback, no-unused-vars, no-return-assign, max-len */ -(function() { - this.BuildArtifacts = (function() { - function BuildArtifacts() { - this.disablePropagation(); - this.setupEntryClick(); - } - BuildArtifacts.prototype.disablePropagation = function() { - $('.top-block').on('click', '.download', function(e) { - return e.stopPropagation(); - }); - return $('.tree-holder').on('click', 'tr[data-link] a', function(e) { - return e.stopImmediatePropagation(); - }); - }; +window.BuildArtifacts = (function() { + function BuildArtifacts() { + this.disablePropagation(); + this.setupEntryClick(); + } - BuildArtifacts.prototype.setupEntryClick = function() { - return $('.tree-holder').on('click', 'tr[data-link]', function(e) { - return window.location = this.dataset.link; - }); - }; + BuildArtifacts.prototype.disablePropagation = function() { + $('.top-block').on('click', '.download', function(e) { + return e.stopPropagation(); + }); + return $('.tree-holder').on('click', 'tr[data-link] a', function(e) { + return e.stopImmediatePropagation(); + }); + }; - return BuildArtifacts; - })(); -}).call(window); + BuildArtifacts.prototype.setupEntryClick = function() { + return $('.tree-holder').on('click', 'tr[data-link]', function(e) { + return window.location = this.dataset.link; + }); + }; + + return BuildArtifacts; +})(); diff --git a/app/assets/javascripts/ci_lint_editor.js b/app/assets/javascripts/ci_lint_editor.js index 56ffaa765a8..dd4a08a2f31 100644 --- a/app/assets/javascripts/ci_lint_editor.js +++ b/app/assets/javascripts/ci_lint_editor.js @@ -1,18 +1,17 @@ -(() => { - window.gl = window.gl || {}; - class CILintEditor { - constructor() { - this.editor = window.ace.edit('ci-editor'); - this.textarea = document.querySelector('#content'); +window.gl = window.gl || {}; - this.editor.getSession().setMode('ace/mode/yaml'); - this.editor.on('input', () => { - const content = this.editor.getSession().getValue(); - this.textarea.value = content; - }); - } +class CILintEditor { + constructor() { + this.editor = window.ace.edit('ci-editor'); + this.textarea = document.querySelector('#content'); + + this.editor.getSession().setMode('ace/mode/yaml'); + this.editor.on('input', () => { + const content = this.editor.getSession().getValue(); + this.textarea.value = content; + }); } +} - gl.CILintEditor = CILintEditor; -})(); +gl.CILintEditor = CILintEditor; diff --git a/app/assets/javascripts/commit.js b/app/assets/javascripts/commit.js index 566b322eb49..5f637524e30 100644 --- a/app/assets/javascripts/commit.js +++ b/app/assets/javascripts/commit.js @@ -1,14 +1,12 @@ /* eslint-disable func-names, space-before-function-paren, wrap-iife */ /* global CommitFile */ -(function() { - this.Commit = (function() { - function Commit() { - $('.files .diff-file').each(function() { - return new CommitFile(this); - }); - } +window.Commit = (function() { + function Commit() { + $('.files .diff-file').each(function() { + return new CommitFile(this); + }); + } - return Commit; - })(); -}).call(window); + return Commit; +})(); diff --git a/app/assets/javascripts/commits.js b/app/assets/javascripts/commits.js index ccd895f3bf4..e3f9eaaf39c 100644 --- a/app/assets/javascripts/commits.js +++ b/app/assets/javascripts/commits.js @@ -1,68 +1,66 @@ /* eslint-disable func-names, space-before-function-paren, wrap-iife, quotes, consistent-return, no-return-assign, no-param-reassign, one-var, no-var, one-var-declaration-per-line, no-unused-vars, prefer-template, object-shorthand, comma-dangle, max-len, prefer-arrow-callback */ /* global Pager */ -(function() { - this.CommitsList = (function() { - var CommitsList = {}; +window.CommitsList = (function() { + var CommitsList = {}; - CommitsList.timer = null; + CommitsList.timer = null; - CommitsList.init = function(limit) { - $("body").on("click", ".day-commits-table li.commit", function(e) { - if (e.target.nodeName !== "A") { - location.href = $(this).attr("url"); - e.stopPropagation(); - return false; - } - }); - Pager.init(limit, false, false, function() { - gl.utils.localTimeAgo($('.js-timeago')); - }); - this.content = $("#commits-list"); - this.searchField = $("#commits-search"); - this.lastSearch = this.searchField.val(); - return this.initSearch(); - }; + CommitsList.init = function(limit) { + $("body").on("click", ".day-commits-table li.commit", function(e) { + if (e.target.nodeName !== "A") { + location.href = $(this).attr("url"); + e.stopPropagation(); + return false; + } + }); + Pager.init(limit, false, false, function() { + gl.utils.localTimeAgo($('.js-timeago')); + }); + this.content = $("#commits-list"); + this.searchField = $("#commits-search"); + this.lastSearch = this.searchField.val(); + return this.initSearch(); + }; - CommitsList.initSearch = function() { - this.timer = null; - return this.searchField.keyup((function(_this) { - return function() { - clearTimeout(_this.timer); - return _this.timer = setTimeout(_this.filterResults, 500); - }; - })(this)); - }; + CommitsList.initSearch = function() { + this.timer = null; + return this.searchField.keyup((function(_this) { + return function() { + clearTimeout(_this.timer); + return _this.timer = setTimeout(_this.filterResults, 500); + }; + })(this)); + }; - CommitsList.filterResults = function() { - var commitsUrl, form, search; - form = $(".commits-search-form"); - search = CommitsList.searchField.val(); - if (search === CommitsList.lastSearch) return; - commitsUrl = form.attr("action") + '?' + form.serialize(); - CommitsList.content.fadeTo('fast', 0.5); - return $.ajax({ - type: "GET", - url: form.attr("action"), - data: form.serialize(), - complete: function() { - return CommitsList.content.fadeTo('fast', 1.0); - }, - success: function(data) { - CommitsList.lastSearch = search; - CommitsList.content.html(data.html); - return history.replaceState({ - page: commitsUrl - // Change url so if user reload a page - search results are saved - }, document.title, commitsUrl); - }, - error: function() { - CommitsList.lastSearch = null; - }, - dataType: "json" - }); - }; + CommitsList.filterResults = function() { + var commitsUrl, form, search; + form = $(".commits-search-form"); + search = CommitsList.searchField.val(); + if (search === CommitsList.lastSearch) return; + commitsUrl = form.attr("action") + '?' + form.serialize(); + CommitsList.content.fadeTo('fast', 0.5); + return $.ajax({ + type: "GET", + url: form.attr("action"), + data: form.serialize(), + complete: function() { + return CommitsList.content.fadeTo('fast', 1.0); + }, + success: function(data) { + CommitsList.lastSearch = search; + CommitsList.content.html(data.html); + return history.replaceState({ + page: commitsUrl + // Change url so if user reload a page - search results are saved + }, document.title, commitsUrl); + }, + error: function() { + CommitsList.lastSearch = null; + }, + dataType: "json" + }); + }; - return CommitsList; - })(); -}).call(window); + return CommitsList; +})(); diff --git a/app/assets/javascripts/compare.js b/app/assets/javascripts/compare.js index 15df105d4cc..9e5dbd64a7e 100644 --- a/app/assets/javascripts/compare.js +++ b/app/assets/javascripts/compare.js @@ -1,91 +1,90 @@ /* eslint-disable func-names, space-before-function-paren, wrap-iife, quotes, no-var, object-shorthand, consistent-return, no-unused-vars, comma-dangle, vars-on-top, prefer-template, max-len */ -(function() { - this.Compare = (function() { - function Compare(opts) { - this.opts = opts; - this.source_loading = $(".js-source-loading"); - this.target_loading = $(".js-target-loading"); - $('.js-compare-dropdown').each((function(_this) { - return function(i, dropdown) { - var $dropdown; - $dropdown = $(dropdown); - return $dropdown.glDropdown({ - selectable: true, - fieldName: $dropdown.data('field-name'), - filterable: true, - id: function(obj, $el) { - return $el.data('id'); - }, - toggleLabel: function(obj, $el) { - return $el.text().trim(); - }, - clicked: function(e, el) { - if ($dropdown.is('.js-target-branch')) { - return _this.getTargetHtml(); - } else if ($dropdown.is('.js-source-branch')) { - return _this.getSourceHtml(); - } else if ($dropdown.is('.js-target-project')) { - return _this.getTargetProject(); - } + +window.Compare = (function() { + function Compare(opts) { + this.opts = opts; + this.source_loading = $(".js-source-loading"); + this.target_loading = $(".js-target-loading"); + $('.js-compare-dropdown').each((function(_this) { + return function(i, dropdown) { + var $dropdown; + $dropdown = $(dropdown); + return $dropdown.glDropdown({ + selectable: true, + fieldName: $dropdown.data('field-name'), + filterable: true, + id: function(obj, $el) { + return $el.data('id'); + }, + toggleLabel: function(obj, $el) { + return $el.text().trim(); + }, + clicked: function(e, el) { + if ($dropdown.is('.js-target-branch')) { + return _this.getTargetHtml(); + } else if ($dropdown.is('.js-source-branch')) { + return _this.getSourceHtml(); + } else if ($dropdown.is('.js-target-project')) { + return _this.getTargetProject(); } - }); - }; - })(this)); - this.initialState(); - } + } + }); + }; + })(this)); + this.initialState(); + } - Compare.prototype.initialState = function() { - this.getSourceHtml(); - return this.getTargetHtml(); - }; + Compare.prototype.initialState = function() { + this.getSourceHtml(); + return this.getTargetHtml(); + }; - Compare.prototype.getTargetProject = function() { - return $.ajax({ - url: this.opts.targetProjectUrl, - data: { - target_project_id: $("input[name='merge_request[target_project_id]']").val() - }, - beforeSend: function() { - return $('.mr_target_commit').empty(); - }, - success: function(html) { - return $('.js-target-branch-dropdown .dropdown-content').html(html); - } - }); - }; + Compare.prototype.getTargetProject = function() { + return $.ajax({ + url: this.opts.targetProjectUrl, + data: { + target_project_id: $("input[name='merge_request[target_project_id]']").val() + }, + beforeSend: function() { + return $('.mr_target_commit').empty(); + }, + success: function(html) { + return $('.js-target-branch-dropdown .dropdown-content').html(html); + } + }); + }; - Compare.prototype.getSourceHtml = function() { - return this.sendAjax(this.opts.sourceBranchUrl, this.source_loading, '.mr_source_commit', { - ref: $("input[name='merge_request[source_branch]']").val() - }); - }; + Compare.prototype.getSourceHtml = function() { + return this.sendAjax(this.opts.sourceBranchUrl, this.source_loading, '.mr_source_commit', { + ref: $("input[name='merge_request[source_branch]']").val() + }); + }; - Compare.prototype.getTargetHtml = function() { - return this.sendAjax(this.opts.targetBranchUrl, this.target_loading, '.mr_target_commit', { - target_project_id: $("input[name='merge_request[target_project_id]']").val(), - ref: $("input[name='merge_request[target_branch]']").val() - }); - }; + Compare.prototype.getTargetHtml = function() { + return this.sendAjax(this.opts.targetBranchUrl, this.target_loading, '.mr_target_commit', { + target_project_id: $("input[name='merge_request[target_project_id]']").val(), + ref: $("input[name='merge_request[target_branch]']").val() + }); + }; - Compare.prototype.sendAjax = function(url, loading, target, data) { - var $target; - $target = $(target); - return $.ajax({ - url: url, - data: data, - beforeSend: function() { - loading.show(); - return $target.empty(); - }, - success: function(html) { - loading.hide(); - $target.html(html); - var className = '.' + $target[0].className.replace(' ', '.'); - gl.utils.localTimeAgo($('.js-timeago', className)); - } - }); - }; + Compare.prototype.sendAjax = function(url, loading, target, data) { + var $target; + $target = $(target); + return $.ajax({ + url: url, + data: data, + beforeSend: function() { + loading.show(); + return $target.empty(); + }, + success: function(html) { + loading.hide(); + $target.html(html); + var className = '.' + $target[0].className.replace(' ', '.'); + gl.utils.localTimeAgo($('.js-timeago', className)); + } + }); + }; - return Compare; - })(); -}).call(window); + return Compare; +})(); diff --git a/app/assets/javascripts/compare_autocomplete.js b/app/assets/javascripts/compare_autocomplete.js index 1eca973e069..d91bfb1ccbd 100644 --- a/app/assets/javascripts/compare_autocomplete.js +++ b/app/assets/javascripts/compare_autocomplete.js @@ -1,69 +1,67 @@ /* eslint-disable func-names, space-before-function-paren, one-var, no-var, one-var-declaration-per-line, object-shorthand, comma-dangle, prefer-arrow-callback, no-else-return, newline-per-chained-call, wrap-iife, max-len */ -(function() { - this.CompareAutocomplete = (function() { - function CompareAutocomplete() { - this.initDropdown(); - } +window.CompareAutocomplete = (function() { + function CompareAutocomplete() { + this.initDropdown(); + } - CompareAutocomplete.prototype.initDropdown = function() { - return $('.js-compare-dropdown').each(function() { - var $dropdown, selected; - $dropdown = $(this); - selected = $dropdown.data('selected'); - const $dropdownContainer = $dropdown.closest('.dropdown'); - const $fieldInput = $(`input[name="${$dropdown.data('field-name')}"]`, $dropdownContainer); - const $filterInput = $('input[type="search"]', $dropdownContainer); - $dropdown.glDropdown({ - data: function(term, callback) { - return $.ajax({ - url: $dropdown.data('refs-url'), - data: { - ref: $dropdown.data('ref') - } - }).done(function(refs) { - return callback(refs); - }); - }, - selectable: true, - filterable: true, - filterByText: true, - fieldName: $dropdown.data('field-name'), - filterInput: 'input[type="search"]', - renderRow: function(ref) { - var link; - if (ref.header != null) { - return $('
  • ').addClass('dropdown-header').text(ref.header); - } else { - link = $('').attr('href', '#').addClass(ref === selected ? 'is-active' : '').text(ref).attr('data-ref', escape(ref)); - return $('
  • ').append(link); + CompareAutocomplete.prototype.initDropdown = function() { + return $('.js-compare-dropdown').each(function() { + var $dropdown, selected; + $dropdown = $(this); + selected = $dropdown.data('selected'); + const $dropdownContainer = $dropdown.closest('.dropdown'); + const $fieldInput = $(`input[name="${$dropdown.data('field-name')}"]`, $dropdownContainer); + const $filterInput = $('input[type="search"]', $dropdownContainer); + $dropdown.glDropdown({ + data: function(term, callback) { + return $.ajax({ + url: $dropdown.data('refs-url'), + data: { + ref: $dropdown.data('ref') } - }, - id: function(obj, $el) { - return $el.attr('data-ref'); - }, - toggleLabel: function(obj, $el) { - return $el.text().trim(); + }).done(function(refs) { + return callback(refs); + }); + }, + selectable: true, + filterable: true, + filterByText: true, + fieldName: $dropdown.data('field-name'), + filterInput: 'input[type="search"]', + renderRow: function(ref) { + var link; + if (ref.header != null) { + return $('
  • ').addClass('dropdown-header').text(ref.header); + } else { + link = $('').attr('href', '#').addClass(ref === selected ? 'is-active' : '').text(ref).attr('data-ref', escape(ref)); + return $('
  • ').append(link); } - }); - $filterInput.on('keyup', (e) => { - const keyCode = e.keyCode || e.which; - if (keyCode !== 13) return; - const text = $filterInput.val(); - $fieldInput.val(text); - $('.dropdown-toggle-text', $dropdown).text(text); - $dropdownContainer.removeClass('open'); - }); + }, + id: function(obj, $el) { + return $el.attr('data-ref'); + }, + toggleLabel: function(obj, $el) { + return $el.text().trim(); + } + }); + $filterInput.on('keyup', (e) => { + const keyCode = e.keyCode || e.which; + if (keyCode !== 13) return; + const text = $filterInput.val(); + $fieldInput.val(text); + $('.dropdown-toggle-text', $dropdown).text(text); + $dropdownContainer.removeClass('open'); + }); - $dropdownContainer.on('click', '.dropdown-content a', (e) => { - $dropdown.prop('title', e.target.text.replace(/_+?/g, '-')); - if ($dropdown.hasClass('has-tooltip')) { - $dropdown.tooltip('fixTitle'); - } - }); + $dropdownContainer.on('click', '.dropdown-content a', (e) => { + $dropdown.prop('title', e.target.text.replace(/_+?/g, '-')); + if ($dropdown.hasClass('has-tooltip')) { + $dropdown.tooltip('fixTitle'); + } }); - }; + }); + }; - return CompareAutocomplete; - })(); -}).call(window); + return CompareAutocomplete; +})(); diff --git a/app/assets/javascripts/confirm_danger_modal.js b/app/assets/javascripts/confirm_danger_modal.js index a1c1b721228..b375b61202e 100644 --- a/app/assets/javascripts/confirm_danger_modal.js +++ b/app/assets/javascripts/confirm_danger_modal.js @@ -1,31 +1,30 @@ /* eslint-disable func-names, space-before-function-paren, wrap-iife, one-var, no-var, camelcase, one-var-declaration-per-line, no-else-return, max-len */ -(function() { - this.ConfirmDangerModal = (function() { - function ConfirmDangerModal(form, text) { - var project_path, submit; - this.form = form; - $('.js-confirm-text').text(text || ''); - $('.js-confirm-danger-input').val(''); - $('#modal-confirm-danger').modal('show'); - project_path = $('.js-confirm-danger-match').text(); - submit = $('.js-confirm-danger-submit'); - submit.disable(); - $('.js-confirm-danger-input').off('input'); - $('.js-confirm-danger-input').on('input', function() { - if (gl.utils.rstrip($(this).val()) === project_path) { - return submit.enable(); - } else { - return submit.disable(); - } - }); - $('.js-confirm-danger-submit').off('click'); - $('.js-confirm-danger-submit').on('click', (function(_this) { - return function() { - return _this.form.submit(); - }; - })(this)); - } - return ConfirmDangerModal; - })(); -}).call(window); +window.ConfirmDangerModal = (function() { + function ConfirmDangerModal(form, text) { + var project_path, submit; + this.form = form; + $('.js-confirm-text').text(text || ''); + $('.js-confirm-danger-input').val(''); + $('#modal-confirm-danger').modal('show'); + project_path = $('.js-confirm-danger-match').text(); + submit = $('.js-confirm-danger-submit'); + submit.disable(); + $('.js-confirm-danger-input').off('input'); + $('.js-confirm-danger-input').on('input', function() { + if (gl.utils.rstrip($(this).val()) === project_path) { + return submit.enable(); + } else { + return submit.disable(); + } + }); + $('.js-confirm-danger-submit').off('click'); + $('.js-confirm-danger-submit').on('click', (function(_this) { + return function() { + return _this.form.submit(); + }; + })(this)); + } + + return ConfirmDangerModal; +})(); diff --git a/app/assets/javascripts/copy_as_gfm.js b/app/assets/javascripts/copy_as_gfm.js index 8883c339335..0fb7bde1fd6 100644 --- a/app/assets/javascripts/copy_as_gfm.js +++ b/app/assets/javascripts/copy_as_gfm.js @@ -1,364 +1,361 @@ /* eslint-disable class-methods-use-this, object-shorthand, no-unused-vars, no-use-before-define, no-new, max-len, no-restricted-syntax, guard-for-in, no-continue */ -/* jshint esversion: 6 */ require('./lib/utils/common_utils'); -(() => { - const gfmRules = { - // The filters referenced in lib/banzai/pipeline/gfm_pipeline.rb convert - // GitLab Flavored Markdown (GFM) to HTML. - // These handlers consequently convert that same HTML to GFM to be copied to the clipboard. - // Every filter in lib/banzai/pipeline/gfm_pipeline.rb that generates HTML - // from GFM should have a handler here, in reverse order. - // The GFM-to-HTML-to-GFM cycle is tested in spec/features/copy_as_gfm_spec.rb. - InlineDiffFilter: { - 'span.idiff.addition'(el, text) { - return `{+${text}+}`; - }, - 'span.idiff.deletion'(el, text) { - return `{-${text}-}`; - }, - }, - TaskListFilter: { - 'input[type=checkbox].task-list-item-checkbox'(el, text) { - return `[${el.checked ? 'x' : ' '}]`; - }, - }, - ReferenceFilter: { - '.tooltip'(el, text) { - return ''; - }, - 'a.gfm:not([data-link=true])'(el, text) { - return el.dataset.original || text; - }, - }, - AutolinkFilter: { - 'a'(el, text) { - // Fallback on the regular MarkdownFilter's `a` handler. - if (text !== el.getAttribute('href')) return false; - - return text; - }, - }, - TableOfContentsFilter: { - 'ul.section-nav'(el, text) { - return '[[_TOC_]]'; - }, - }, - EmojiFilter: { - 'img.emoji'(el, text) { - return el.getAttribute('alt'); - }, - 'gl-emoji'(el, text) { - return `:${el.getAttribute('data-name')}:`; - }, - }, - ImageLinkFilter: { - 'a.no-attachment-icon'(el, text) { - return text; - }, - }, - VideoLinkFilter: { - '.video-container'(el, text) { - const videoEl = el.querySelector('video'); - if (!videoEl) return false; - - return CopyAsGFM.nodeToGFM(videoEl); - }, - 'video'(el, text) { - return `![${el.dataset.title}](${el.getAttribute('src')})`; - }, - }, - MathFilter: { - 'pre.code.math[data-math-style=display]'(el, text) { - return `\`\`\`math\n${text.trim()}\n\`\`\``; - }, - 'code.code.math[data-math-style=inline]'(el, text) { - return `$\`${text}\`$`; - }, - 'span.katex-display span.katex-mathml'(el, text) { - const mathAnnotation = el.querySelector('annotation[encoding="application/x-tex"]'); - if (!mathAnnotation) return false; - - return `\`\`\`math\n${CopyAsGFM.nodeToGFM(mathAnnotation)}\n\`\`\``; - }, - 'span.katex-mathml'(el, text) { - const mathAnnotation = el.querySelector('annotation[encoding="application/x-tex"]'); - if (!mathAnnotation) return false; - - return `$\`${CopyAsGFM.nodeToGFM(mathAnnotation)}\`$`; - }, - 'span.katex-html'(el, text) { - // We don't want to include the content of this element in the copied text. - return ''; - }, - 'annotation[encoding="application/x-tex"]'(el, text) { - return text.trim(); - }, - }, - SanitizationFilter: { - 'a[name]:not([href]):empty'(el, text) { - return el.outerHTML; - }, - 'dl'(el, text) { - let lines = text.trim().split('\n'); - // Add two spaces to the front of subsequent list items lines, - // or leave the line entirely blank. - lines = lines.map((l) => { +const gfmRules = { + // The filters referenced in lib/banzai/pipeline/gfm_pipeline.rb convert + // GitLab Flavored Markdown (GFM) to HTML. + // These handlers consequently convert that same HTML to GFM to be copied to the clipboard. + // Every filter in lib/banzai/pipeline/gfm_pipeline.rb that generates HTML + // from GFM should have a handler here, in reverse order. + // The GFM-to-HTML-to-GFM cycle is tested in spec/features/copy_as_gfm_spec.rb. + InlineDiffFilter: { + 'span.idiff.addition'(el, text) { + return `{+${text}+}`; + }, + 'span.idiff.deletion'(el, text) { + return `{-${text}-}`; + }, + }, + TaskListFilter: { + 'input[type=checkbox].task-list-item-checkbox'(el, text) { + return `[${el.checked ? 'x' : ' '}]`; + }, + }, + ReferenceFilter: { + '.tooltip'(el, text) { + return ''; + }, + 'a.gfm:not([data-link=true])'(el, text) { + return el.dataset.original || text; + }, + }, + AutolinkFilter: { + 'a'(el, text) { + // Fallback on the regular MarkdownFilter's `a` handler. + if (text !== el.getAttribute('href')) return false; + + return text; + }, + }, + TableOfContentsFilter: { + 'ul.section-nav'(el, text) { + return '[[_TOC_]]'; + }, + }, + EmojiFilter: { + 'img.emoji'(el, text) { + return el.getAttribute('alt'); + }, + 'gl-emoji'(el, text) { + return `:${el.getAttribute('data-name')}:`; + }, + }, + ImageLinkFilter: { + 'a.no-attachment-icon'(el, text) { + return text; + }, + }, + VideoLinkFilter: { + '.video-container'(el, text) { + const videoEl = el.querySelector('video'); + if (!videoEl) return false; + + return CopyAsGFM.nodeToGFM(videoEl); + }, + 'video'(el, text) { + return `![${el.dataset.title}](${el.getAttribute('src')})`; + }, + }, + MathFilter: { + 'pre.code.math[data-math-style=display]'(el, text) { + return `\`\`\`math\n${text.trim()}\n\`\`\``; + }, + 'code.code.math[data-math-style=inline]'(el, text) { + return `$\`${text}\`$`; + }, + 'span.katex-display span.katex-mathml'(el, text) { + const mathAnnotation = el.querySelector('annotation[encoding="application/x-tex"]'); + if (!mathAnnotation) return false; + + return `\`\`\`math\n${CopyAsGFM.nodeToGFM(mathAnnotation)}\n\`\`\``; + }, + 'span.katex-mathml'(el, text) { + const mathAnnotation = el.querySelector('annotation[encoding="application/x-tex"]'); + if (!mathAnnotation) return false; + + return `$\`${CopyAsGFM.nodeToGFM(mathAnnotation)}\`$`; + }, + 'span.katex-html'(el, text) { + // We don't want to include the content of this element in the copied text. + return ''; + }, + 'annotation[encoding="application/x-tex"]'(el, text) { + return text.trim(); + }, + }, + SanitizationFilter: { + 'a[name]:not([href]):empty'(el, text) { + return el.outerHTML; + }, + 'dl'(el, text) { + let lines = text.trim().split('\n'); + // Add two spaces to the front of subsequent list items lines, + // or leave the line entirely blank. + lines = lines.map((l) => { + const line = l.trim(); + if (line.length === 0) return ''; + + return ` ${line}`; + }); + + return `
    \n${lines.join('\n')}\n
    `; + }, + 'sub, dt, dd, kbd, q, samp, var, ruby, rt, rp, abbr, summary, details'(el, text) { + const tag = el.nodeName.toLowerCase(); + return `<${tag}>${text}`; + }, + }, + SyntaxHighlightFilter: { + 'pre.code.highlight'(el, t) { + const text = t.trim(); + + let lang = el.getAttribute('lang'); + if (lang === 'plaintext') { + lang = ''; + } + + // Prefixes lines with 4 spaces if the code contains triple backticks + if (lang === '' && text.match(/^```/gm)) { + return text.split('\n').map((l) => { const line = l.trim(); if (line.length === 0) return ''; - return ` ${line}`; - }); + return ` ${line}`; + }).join('\n'); + } - return `
    \n${lines.join('\n')}\n
    `; - }, - 'sub, dt, dd, kbd, q, samp, var, ruby, rt, rp, abbr, summary, details'(el, text) { - const tag = el.nodeName.toLowerCase(); - return `<${tag}>${text}`; - }, + return `\`\`\`${lang}\n${text}\n\`\`\``; + }, + 'pre > code'(el, text) { + // Don't wrap code blocks in `` + return text; + }, + }, + MarkdownFilter: { + 'br'(el, text) { + // Two spaces at the end of a line are turned into a BR + return ' '; }, - SyntaxHighlightFilter: { - 'pre.code.highlight'(el, t) { - const text = t.trim(); + 'code'(el, text) { + let backtickCount = 1; + const backtickMatch = text.match(/`+/); + if (backtickMatch) { + backtickCount = backtickMatch[0].length + 1; + } - let lang = el.getAttribute('lang'); - if (lang === 'plaintext') { - lang = ''; - } + const backticks = Array(backtickCount + 1).join('`'); + const spaceOrNoSpace = backtickCount > 1 ? ' ' : ''; - // Prefixes lines with 4 spaces if the code contains triple backticks - if (lang === '' && text.match(/^```/gm)) { - return text.split('\n').map((l) => { - const line = l.trim(); - if (line.length === 0) return ''; + return backticks + spaceOrNoSpace + text + spaceOrNoSpace + backticks; + }, + 'blockquote'(el, text) { + return text.trim().split('\n').map(s => `> ${s}`.trim()).join('\n'); + }, + 'img'(el, text) { + return `![${el.getAttribute('alt')}](${el.getAttribute('src')})`; + }, + 'a.anchor'(el, text) { + // Don't render a Markdown link for the anchor link inside a heading + return text; + }, + 'a'(el, text) { + return `[${text}](${el.getAttribute('href')})`; + }, + 'li'(el, text) { + const lines = text.trim().split('\n'); + const firstLine = `- ${lines.shift()}`; + // Add four spaces to the front of subsequent list items lines, + // or leave the line entirely blank. + const nextLines = lines.map((s) => { + if (s.trim().length === 0) return ''; + + return ` ${s}`; + }); + + return `${firstLine}\n${nextLines.join('\n')}`; + }, + 'ul'(el, text) { + return text; + }, + 'ol'(el, text) { + // LIs get a `- ` prefix by default, which we replace by `1. ` for ordered lists. + return text.replace(/^- /mg, '1. '); + }, + 'h1'(el, text) { + return `# ${text.trim()}`; + }, + 'h2'(el, text) { + return `## ${text.trim()}`; + }, + 'h3'(el, text) { + return `### ${text.trim()}`; + }, + 'h4'(el, text) { + return `#### ${text.trim()}`; + }, + 'h5'(el, text) { + return `##### ${text.trim()}`; + }, + 'h6'(el, text) { + return `###### ${text.trim()}`; + }, + 'strong'(el, text) { + return `**${text}**`; + }, + 'em'(el, text) { + return `_${text}_`; + }, + 'del'(el, text) { + return `~~${text}~~`; + }, + 'sup'(el, text) { + return `^${text}`; + }, + 'hr'(el, text) { + return '-----'; + }, + 'table'(el, text) { + const theadEl = el.querySelector('thead'); + const tbodyEl = el.querySelector('tbody'); + if (!theadEl || !tbodyEl) return false; - return ` ${line}`; - }).join('\n'); - } + const theadText = CopyAsGFM.nodeToGFM(theadEl); + const tbodyText = CopyAsGFM.nodeToGFM(tbodyEl); - return `\`\`\`${lang}\n${text}\n\`\`\``; - }, - 'pre > code'(el, text) { - // Don't wrap code blocks in `` - return text; - }, - }, - MarkdownFilter: { - 'br'(el, text) { - // Two spaces at the end of a line are turned into a BR - return ' '; - }, - 'code'(el, text) { - let backtickCount = 1; - const backtickMatch = text.match(/`+/); - if (backtickMatch) { - backtickCount = backtickMatch[0].length + 1; + return theadText + tbodyText; + }, + 'thead'(el, text) { + const cells = _.map(el.querySelectorAll('th'), (cell) => { + let chars = CopyAsGFM.nodeToGFM(cell).trim().length + 2; + + let before = ''; + let after = ''; + switch (cell.style.textAlign) { + case 'center': + before = ':'; + after = ':'; + chars -= 2; + break; + case 'right': + after = ':'; + chars -= 1; + break; + default: + break; } - const backticks = Array(backtickCount + 1).join('`'); - const spaceOrNoSpace = backtickCount > 1 ? ' ' : ''; - - return backticks + spaceOrNoSpace + text + spaceOrNoSpace + backticks; - }, - 'blockquote'(el, text) { - return text.trim().split('\n').map(s => `> ${s}`.trim()).join('\n'); - }, - 'img'(el, text) { - return `![${el.getAttribute('alt')}](${el.getAttribute('src')})`; - }, - 'a.anchor'(el, text) { - // Don't render a Markdown link for the anchor link inside a heading - return text; - }, - 'a'(el, text) { - return `[${text}](${el.getAttribute('href')})`; - }, - 'li'(el, text) { - const lines = text.trim().split('\n'); - const firstLine = `- ${lines.shift()}`; - // Add four spaces to the front of subsequent list items lines, - // or leave the line entirely blank. - const nextLines = lines.map((s) => { - if (s.trim().length === 0) return ''; - - return ` ${s}`; - }); - - return `${firstLine}\n${nextLines.join('\n')}`; - }, - 'ul'(el, text) { - return text; - }, - 'ol'(el, text) { - // LIs get a `- ` prefix by default, which we replace by `1. ` for ordered lists. - return text.replace(/^- /mg, '1. '); - }, - 'h1'(el, text) { - return `# ${text.trim()}`; - }, - 'h2'(el, text) { - return `## ${text.trim()}`; - }, - 'h3'(el, text) { - return `### ${text.trim()}`; - }, - 'h4'(el, text) { - return `#### ${text.trim()}`; - }, - 'h5'(el, text) { - return `##### ${text.trim()}`; - }, - 'h6'(el, text) { - return `###### ${text.trim()}`; - }, - 'strong'(el, text) { - return `**${text}**`; - }, - 'em'(el, text) { - return `_${text}_`; - }, - 'del'(el, text) { - return `~~${text}~~`; - }, - 'sup'(el, text) { - return `^${text}`; - }, - 'hr'(el, text) { - return '-----'; - }, - 'table'(el, text) { - const theadEl = el.querySelector('thead'); - const tbodyEl = el.querySelector('tbody'); - if (!theadEl || !tbodyEl) return false; - - const theadText = CopyAsGFM.nodeToGFM(theadEl); - const tbodyText = CopyAsGFM.nodeToGFM(tbodyEl); - - return theadText + tbodyText; - }, - 'thead'(el, text) { - const cells = _.map(el.querySelectorAll('th'), (cell) => { - let chars = CopyAsGFM.nodeToGFM(cell).trim().length + 2; - - let before = ''; - let after = ''; - switch (cell.style.textAlign) { - case 'center': - before = ':'; - after = ':'; - chars -= 2; - break; - case 'right': - after = ':'; - chars -= 1; - break; - default: - break; - } - - chars = Math.max(chars, 3); - - const middle = Array(chars + 1).join('-'); - - return before + middle + after; - }); - - return `${text}|${cells.join('|')}|`; - }, - 'tr'(el, text) { - const cells = _.map(el.querySelectorAll('td, th'), cell => CopyAsGFM.nodeToGFM(cell).trim()); - return `| ${cells.join(' | ')} |`; - }, - }, - }; - - class CopyAsGFM { - constructor() { - $(document).on('copy', '.md, .wiki', this.handleCopy); - $(document).on('paste', '.js-gfm-input', this.handlePaste); - } - - handleCopy(e) { - const clipboardData = e.originalEvent.clipboardData; - if (!clipboardData) return; - - const documentFragment = window.gl.utils.getSelectedFragment(); - if (!documentFragment) return; + chars = Math.max(chars, 3); - // If the documentFragment contains more than just Markdown, don't copy as GFM. - if (documentFragment.querySelector('.md, .wiki')) return; + const middle = Array(chars + 1).join('-'); - e.preventDefault(); - clipboardData.setData('text/plain', documentFragment.textContent); + return before + middle + after; + }); - const gfm = CopyAsGFM.nodeToGFM(documentFragment); - clipboardData.setData('text/x-gfm', gfm); - } + return `${text}|${cells.join('|')}|`; + }, + 'tr'(el, text) { + const cells = _.map(el.querySelectorAll('td, th'), cell => CopyAsGFM.nodeToGFM(cell).trim()); + return `| ${cells.join(' | ')} |`; + }, + }, +}; - handlePaste(e) { - const clipboardData = e.originalEvent.clipboardData; - if (!clipboardData) return; +class CopyAsGFM { + constructor() { + $(document).on('copy', '.md, .wiki', this.handleCopy); + $(document).on('paste', '.js-gfm-input', this.handlePaste); + } - const gfm = clipboardData.getData('text/x-gfm'); - if (!gfm) return; + handleCopy(e) { + const clipboardData = e.originalEvent.clipboardData; + if (!clipboardData) return; - e.preventDefault(); + const documentFragment = window.gl.utils.getSelectedFragment(); + if (!documentFragment) return; - window.gl.utils.insertText(e.target, gfm); - } + // If the documentFragment contains more than just Markdown, don't copy as GFM. + if (documentFragment.querySelector('.md, .wiki')) return; - static nodeToGFM(node) { - if (node.nodeType === Node.TEXT_NODE) { - return node.textContent; - } + e.preventDefault(); + clipboardData.setData('text/plain', documentFragment.textContent); - const text = this.innerGFM(node); + const gfm = CopyAsGFM.nodeToGFM(documentFragment); + clipboardData.setData('text/x-gfm', gfm); + } - if (node.nodeType === Node.DOCUMENT_FRAGMENT_NODE) { - return text; - } + handlePaste(e) { + const clipboardData = e.originalEvent.clipboardData; + if (!clipboardData) return; - for (const filter in gfmRules) { - const rules = gfmRules[filter]; + const gfm = clipboardData.getData('text/x-gfm'); + if (!gfm) return; - for (const selector in rules) { - const func = rules[selector]; + e.preventDefault(); - if (!window.gl.utils.nodeMatchesSelector(node, selector)) continue; + window.gl.utils.insertText(e.target, gfm); + } - const result = func(node, text); - if (result === false) continue; + static nodeToGFM(node) { + if (node.nodeType === Node.TEXT_NODE) { + return node.textContent; + } - return result; - } - } + const text = this.innerGFM(node); + if (node.nodeType === Node.DOCUMENT_FRAGMENT_NODE) { return text; } - static innerGFM(parentNode) { - const nodes = parentNode.childNodes; + for (const filter in gfmRules) { + const rules = gfmRules[filter]; - const clonedParentNode = parentNode.cloneNode(true); - const clonedNodes = Array.prototype.slice.call(clonedParentNode.childNodes, 0); + for (const selector in rules) { + const func = rules[selector]; - for (let i = 0; i < nodes.length; i += 1) { - const node = nodes[i]; - const clonedNode = clonedNodes[i]; + if (!window.gl.utils.nodeMatchesSelector(node, selector)) continue; - const text = this.nodeToGFM(node); + const result = func(node, text); + if (result === false) continue; - // `clonedNode.replaceWith(text)` is not yet widely supported - clonedNode.parentNode.replaceChild(document.createTextNode(text), clonedNode); + return result; } + } + + return text; + } - return clonedParentNode.innerText || clonedParentNode.textContent; + static innerGFM(parentNode) { + const nodes = parentNode.childNodes; + + const clonedParentNode = parentNode.cloneNode(true); + const clonedNodes = Array.prototype.slice.call(clonedParentNode.childNodes, 0); + + for (let i = 0; i < nodes.length; i += 1) { + const node = nodes[i]; + const clonedNode = clonedNodes[i]; + + const text = this.nodeToGFM(node); + + // `clonedNode.replaceWith(text)` is not yet widely supported + clonedNode.parentNode.replaceChild(document.createTextNode(text), clonedNode); } + + return clonedParentNode.innerText || clonedParentNode.textContent; } +} - window.gl = window.gl || {}; - window.gl.CopyAsGFM = CopyAsGFM; +window.gl = window.gl || {}; +window.gl.CopyAsGFM = CopyAsGFM; - new CopyAsGFM(); -})(); +new CopyAsGFM(); diff --git a/app/assets/javascripts/copy_to_clipboard.js b/app/assets/javascripts/copy_to_clipboard.js index 615f485e18a..6dbec50b890 100644 --- a/app/assets/javascripts/copy_to_clipboard.js +++ b/app/assets/javascripts/copy_to_clipboard.js @@ -1,49 +1,46 @@ /* eslint-disable func-names, space-before-function-paren, one-var, no-var, one-var-declaration-per-line, prefer-template, quotes, no-unused-vars, prefer-arrow-callback, max-len */ -/* global Clipboard */ - -window.Clipboard = require('vendor/clipboard'); - -(function() { - var genericError, genericSuccess, showTooltip; - - genericSuccess = function(e) { - showTooltip(e.trigger, 'Copied'); - // Clear the selection and blur the trigger so it loses its border - e.clearSelection(); - return $(e.trigger).blur(); - }; - - // Safari doesn't support `execCommand`, so instead we inform the user to - // copy manually. - // - // See http://clipboardjs.com/#browser-support - genericError = function(e) { - var key; - if (/Mac/i.test(navigator.userAgent)) { - key = '⌘'; // Command - } else { - key = 'Ctrl'; - } - return showTooltip(e.trigger, "Press " + key + "-C to copy"); - }; - - showTooltip = function(target, title) { - var $target = $(target); - var originalTitle = $target.data('original-title'); - - $target - .attr('title', 'Copied') - .tooltip('fixTitle') - .tooltip('show') - .attr('title', originalTitle) - .tooltip('fixTitle'); - }; - - $(function() { - var clipboard; - - clipboard = new Clipboard('[data-clipboard-target], [data-clipboard-text]'); - clipboard.on('success', genericSuccess); - return clipboard.on('error', genericError); - }); -}).call(window); + +import Clipboard from 'vendor/clipboard'; + +var genericError, genericSuccess, showTooltip; + +genericSuccess = function(e) { + showTooltip(e.trigger, 'Copied'); + // Clear the selection and blur the trigger so it loses its border + e.clearSelection(); + return $(e.trigger).blur(); +}; + +// Safari doesn't support `execCommand`, so instead we inform the user to +// copy manually. +// +// See http://clipboardjs.com/#browser-support +genericError = function(e) { + var key; + if (/Mac/i.test(navigator.userAgent)) { + key = '⌘'; // Command + } else { + key = 'Ctrl'; + } + return showTooltip(e.trigger, "Press " + key + "-C to copy"); +}; + +showTooltip = function(target, title) { + var $target = $(target); + var originalTitle = $target.data('original-title'); + + $target + .attr('title', 'Copied') + .tooltip('fixTitle') + .tooltip('show') + .attr('title', originalTitle) + .tooltip('fixTitle'); +}; + +$(function() { + var clipboard; + + clipboard = new Clipboard('[data-clipboard-target], [data-clipboard-text]'); + clipboard.on('success', genericSuccess); + return clipboard.on('error', genericError); +}); diff --git a/app/assets/javascripts/create_label.js b/app/assets/javascripts/create_label.js index 85384d98126..121d64db789 100644 --- a/app/assets/javascripts/create_label.js +++ b/app/assets/javascripts/create_label.js @@ -1,132 +1,127 @@ /* eslint-disable func-names, space-before-function-paren, prefer-arrow-callback, comma-dangle, prefer-template, quotes, no-param-reassign, wrap-iife, max-len */ /* global Api */ -(function (w) { - class CreateLabelDropdown { - constructor ($el, namespacePath, projectPath) { - this.$el = $el; - this.namespacePath = namespacePath; - this.projectPath = projectPath; - this.$dropdownBack = $('.dropdown-menu-back', this.$el.closest('.dropdown')); - this.$cancelButton = $('.js-cancel-label-btn', this.$el); - this.$newLabelField = $('#new_label_name', this.$el); - this.$newColorField = $('#new_label_color', this.$el); - this.$colorPreview = $('.js-dropdown-label-color-preview', this.$el); - this.$newLabelError = $('.js-label-error', this.$el); - this.$newLabelCreateButton = $('.js-new-label-btn', this.$el); - this.$colorSuggestions = $('.suggest-colors-dropdown a', this.$el); - - this.$newLabelError.hide(); - this.$newLabelCreateButton.disable(); +class CreateLabelDropdown { + constructor ($el, namespacePath, projectPath) { + this.$el = $el; + this.namespacePath = namespacePath; + this.projectPath = projectPath; + this.$dropdownBack = $('.dropdown-menu-back', this.$el.closest('.dropdown')); + this.$cancelButton = $('.js-cancel-label-btn', this.$el); + this.$newLabelField = $('#new_label_name', this.$el); + this.$newColorField = $('#new_label_color', this.$el); + this.$colorPreview = $('.js-dropdown-label-color-preview', this.$el); + this.$newLabelError = $('.js-label-error', this.$el); + this.$newLabelCreateButton = $('.js-new-label-btn', this.$el); + this.$colorSuggestions = $('.suggest-colors-dropdown a', this.$el); + + this.$newLabelError.hide(); + this.$newLabelCreateButton.disable(); + + this.cleanBinding(); + this.addBinding(); + } - this.cleanBinding(); - this.addBinding(); - } + cleanBinding () { + this.$colorSuggestions.off('click'); + this.$newLabelField.off('keyup change'); + this.$newColorField.off('keyup change'); + this.$dropdownBack.off('click'); + this.$cancelButton.off('click'); + this.$newLabelCreateButton.off('click'); + } - cleanBinding () { - this.$colorSuggestions.off('click'); - this.$newLabelField.off('keyup change'); - this.$newColorField.off('keyup change'); - this.$dropdownBack.off('click'); - this.$cancelButton.off('click'); - this.$newLabelCreateButton.off('click'); - } + addBinding () { + const self = this; - addBinding () { - const self = this; + this.$colorSuggestions.on('click', function (e) { + const $this = $(this); + self.addColorValue(e, $this); + }); - this.$colorSuggestions.on('click', function (e) { - const $this = $(this); - self.addColorValue(e, $this); - }); + this.$newLabelField.on('keyup change', this.enableLabelCreateButton.bind(this)); + this.$newColorField.on('keyup change', this.enableLabelCreateButton.bind(this)); - this.$newLabelField.on('keyup change', this.enableLabelCreateButton.bind(this)); - this.$newColorField.on('keyup change', this.enableLabelCreateButton.bind(this)); + this.$dropdownBack.on('click', this.resetForm.bind(this)); - this.$dropdownBack.on('click', this.resetForm.bind(this)); + this.$cancelButton.on('click', function(e) { + e.preventDefault(); + e.stopPropagation(); - this.$cancelButton.on('click', function(e) { - e.preventDefault(); - e.stopPropagation(); + self.resetForm(); + self.$dropdownBack.trigger('click'); + }); - self.resetForm(); - self.$dropdownBack.trigger('click'); - }); + this.$newLabelCreateButton.on('click', this.saveLabel.bind(this)); + } - this.$newLabelCreateButton.on('click', this.saveLabel.bind(this)); - } + addColorValue (e, $this) { + e.preventDefault(); + e.stopPropagation(); - addColorValue (e, $this) { - e.preventDefault(); - e.stopPropagation(); + this.$newColorField.val($this.data('color')).trigger('change'); + this.$colorPreview + .css('background-color', $this.data('color')) + .parent() + .addClass('is-active'); + } - this.$newColorField.val($this.data('color')).trigger('change'); - this.$colorPreview - .css('background-color', $this.data('color')) - .parent() - .addClass('is-active'); + enableLabelCreateButton () { + if (this.$newLabelField.val() !== '' && this.$newColorField.val() !== '') { + this.$newLabelError.hide(); + this.$newLabelCreateButton.enable(); + } else { + this.$newLabelCreateButton.disable(); } + } - enableLabelCreateButton () { - if (this.$newLabelField.val() !== '' && this.$newColorField.val() !== '') { - this.$newLabelError.hide(); - this.$newLabelCreateButton.enable(); - } else { - this.$newLabelCreateButton.disable(); - } - } + resetForm () { + this.$newLabelField + .val('') + .trigger('change'); - resetForm () { - this.$newLabelField - .val('') - .trigger('change'); + this.$newColorField + .val('') + .trigger('change'); - this.$newColorField - .val('') - .trigger('change'); + this.$colorPreview + .css('background-color', '') + .parent() + .removeClass('is-active'); + } - this.$colorPreview - .css('background-color', '') - .parent() - .removeClass('is-active'); - } + saveLabel (e) { + e.preventDefault(); + e.stopPropagation(); - saveLabel (e) { - e.preventDefault(); - e.stopPropagation(); + Api.newLabel(this.namespacePath, this.projectPath, { + title: this.$newLabelField.val(), + color: this.$newColorField.val() + }, (label) => { + this.$newLabelCreateButton.enable(); - Api.newLabel(this.namespacePath, this.projectPath, { - title: this.$newLabelField.val(), - color: this.$newColorField.val() - }, (label) => { - this.$newLabelCreateButton.enable(); - - if (label.message) { - let errors; - - if (typeof label.message === 'string') { - errors = label.message; - } else { - errors = Object.keys(label.message).map(key => - `${gl.text.humanize(key)} ${label.message[key].join(', ')}` - ).join("
    "); - } - - this.$newLabelError - .html(errors) - .show(); - } else { - this.$dropdownBack.trigger('click'); + if (label.message) { + let errors; - $(document).trigger('created.label', label); + if (typeof label.message === 'string') { + errors = label.message; + } else { + errors = Object.keys(label.message).map(key => + `${gl.text.humanize(key)} ${label.message[key].join(', ')}` + ).join("
    "); } - }); - } - } - if (!w.gl) { - w.gl = {}; + this.$newLabelError + .html(errors) + .show(); + } else { + this.$dropdownBack.trigger('click'); + + $(document).trigger('created.label', label); + } + }); } +} - gl.CreateLabelDropdown = CreateLabelDropdown; -})(window); +window.gl = window.gl || {}; +gl.CreateLabelDropdown = CreateLabelDropdown; diff --git a/app/assets/javascripts/diff.js b/app/assets/javascripts/diff.js index 6829e8aeaea..cfa60325fcc 100644 --- a/app/assets/javascripts/diff.js +++ b/app/assets/javascripts/diff.js @@ -2,129 +2,127 @@ require('./lib/utils/url_utility'); -(() => { - const UNFOLD_COUNT = 20; - let isBound = false; +const UNFOLD_COUNT = 20; +let isBound = false; - class Diff { - constructor() { - const $diffFile = $('.files .diff-file'); - $diffFile.singleFileDiff(); - $diffFile.filesCommentButton(); +class Diff { + constructor() { + const $diffFile = $('.files .diff-file'); + $diffFile.singleFileDiff(); + $diffFile.filesCommentButton(); - $diffFile.each((index, file) => new gl.ImageFile(file)); + $diffFile.each((index, file) => new gl.ImageFile(file)); - if (this.diffViewType() === 'parallel') { - $('.content-wrapper .container-fluid').removeClass('container-limited'); - } - - if (!isBound) { - $(document) - .on('click', '.js-unfold', this.handleClickUnfold.bind(this)) - .on('click', '.diff-line-num a', this.handleClickLineNum.bind(this)); - isBound = true; - } + if (this.diffViewType() === 'parallel') { + $('.content-wrapper .container-fluid').removeClass('container-limited'); + } - if (gl.utils.getLocationHash()) { - this.highlightSelectedLine(); - } + if (!isBound) { + $(document) + .on('click', '.js-unfold', this.handleClickUnfold.bind(this)) + .on('click', '.diff-line-num a', this.handleClickLineNum.bind(this)); + isBound = true; + } - this.openAnchoredDiff(); + if (gl.utils.getLocationHash()) { + this.highlightSelectedLine(); } - handleClickUnfold(e) { - const $target = $(e.target); - // current babel config relies on iterators implementation, so we cannot simply do: - // const [oldLineNumber, newLineNumber] = this.lineNumbers($target.parent()); - const ref = this.lineNumbers($target.parent()); - const oldLineNumber = ref[0]; - const newLineNumber = ref[1]; - const offset = newLineNumber - oldLineNumber; - const bottom = $target.hasClass('js-unfold-bottom'); - let since; - let to; - let unfold = true; - - if (bottom) { - const lineNumber = newLineNumber + 1; - since = lineNumber; - to = lineNumber + UNFOLD_COUNT; - } else { - const lineNumber = newLineNumber - 1; - since = lineNumber - UNFOLD_COUNT; - to = lineNumber; - - // make sure we aren't loading more than we need - const prevNewLine = this.lineNumbers($target.parent().prev())[1]; - if (since <= prevNewLine + 1) { - since = prevNewLine + 1; - unfold = false; - } + this.openAnchoredDiff(); + } + + handleClickUnfold(e) { + const $target = $(e.target); + // current babel config relies on iterators implementation, so we cannot simply do: + // const [oldLineNumber, newLineNumber] = this.lineNumbers($target.parent()); + const ref = this.lineNumbers($target.parent()); + const oldLineNumber = ref[0]; + const newLineNumber = ref[1]; + const offset = newLineNumber - oldLineNumber; + const bottom = $target.hasClass('js-unfold-bottom'); + let since; + let to; + let unfold = true; + + if (bottom) { + const lineNumber = newLineNumber + 1; + since = lineNumber; + to = lineNumber + UNFOLD_COUNT; + } else { + const lineNumber = newLineNumber - 1; + since = lineNumber - UNFOLD_COUNT; + to = lineNumber; + + // make sure we aren't loading more than we need + const prevNewLine = this.lineNumbers($target.parent().prev())[1]; + if (since <= prevNewLine + 1) { + since = prevNewLine + 1; + unfold = false; } + } - const file = $target.parents('.diff-file'); - const link = file.data('blob-diff-path'); - const view = file.data('view'); + const file = $target.parents('.diff-file'); + const link = file.data('blob-diff-path'); + const view = file.data('view'); - const params = { since, to, bottom, offset, unfold, view }; - $.get(link, params, response => $target.parent().replaceWith(response)); - } + const params = { since, to, bottom, offset, unfold, view }; + $.get(link, params, response => $target.parent().replaceWith(response)); + } - openAnchoredDiff(cb) { - const locationHash = gl.utils.getLocationHash(); - const anchoredDiff = locationHash && locationHash.split('_')[0]; - - if (!anchoredDiff) return; - - const diffTitle = $(`#${anchoredDiff}`); - const diffFile = diffTitle.closest('.diff-file'); - const nothingHereBlock = $('.nothing-here-block:visible', diffFile); - if (nothingHereBlock.length) { - const clickTarget = $('.js-file-title, .click-to-expand', diffFile); - diffFile.data('singleFileDiff').toggleDiff(clickTarget, () => { - this.highlightSelectedLine(); - if (cb) cb(); - }); - } else if (cb) { - cb(); - } - } + openAnchoredDiff(cb) { + const locationHash = gl.utils.getLocationHash(); + const anchoredDiff = locationHash && locationHash.split('_')[0]; - handleClickLineNum(e) { - const hash = $(e.currentTarget).attr('href'); - e.preventDefault(); - if (window.history.pushState) { - window.history.pushState(null, null, hash); - } else { - window.location.hash = hash; - } - this.highlightSelectedLine(); + if (!anchoredDiff) return; + + const diffTitle = $(`#${anchoredDiff}`); + const diffFile = diffTitle.closest('.diff-file'); + const nothingHereBlock = $('.nothing-here-block:visible', diffFile); + if (nothingHereBlock.length) { + const clickTarget = $('.js-file-title, .click-to-expand', diffFile); + diffFile.data('singleFileDiff').toggleDiff(clickTarget, () => { + this.highlightSelectedLine(); + if (cb) cb(); + }); + } else if (cb) { + cb(); } + } - diffViewType() { - return $('.inline-parallel-buttons a.active').data('view-type'); + handleClickLineNum(e) { + const hash = $(e.currentTarget).attr('href'); + e.preventDefault(); + if (window.history.pushState) { + window.history.pushState(null, null, hash); + } else { + window.location.hash = hash; } + this.highlightSelectedLine(); + } - lineNumbers(line) { - if (!line.children().length) { - return [0, 0]; - } - return line.find('.diff-line-num').map((i, elm) => parseInt($(elm).data('linenumber'), 10)); + diffViewType() { + return $('.inline-parallel-buttons a.active').data('view-type'); + } + + lineNumbers(line) { + if (!line.children().length) { + return [0, 0]; } + return line.find('.diff-line-num').map((i, elm) => parseInt($(elm).data('linenumber'), 10)); + } - highlightSelectedLine() { - const hash = gl.utils.getLocationHash(); - const $diffFiles = $('.diff-file'); - $diffFiles.find('.hll').removeClass('hll'); + highlightSelectedLine() { + const hash = gl.utils.getLocationHash(); + const $diffFiles = $('.diff-file'); + $diffFiles.find('.hll').removeClass('hll'); - if (hash) { - $diffFiles - .find(`tr#${hash}:not(.match) td, td#${hash}, td[data-line-code="${hash}"]`) - .addClass('hll'); - } + if (hash) { + $diffFiles + .find(`tr#${hash}:not(.match) td, td#${hash}, td[data-line-code="${hash}"]`) + .addClass('hll'); } } +} - window.gl = window.gl || {}; - window.gl.Diff = Diff; -})(); +window.gl = window.gl || {}; +window.gl.Diff = Diff; diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js index 646f836aff0..f2963a5eb19 100644 --- a/app/assets/javascripts/dropzone_input.js +++ b/app/assets/javascripts/dropzone_input.js @@ -3,218 +3,216 @@ require('./preview_markdown'); -(function() { - this.DropzoneInput = (function() { - function DropzoneInput(form) { - var $mdArea, alertAttr, alertClass, appendToTextArea, btnAlert, child, closeAlertMessage, closeSpinner, divAlert, divHover, divSpinner, dropzone, form_dropzone, form_textarea, getFilename, handlePaste, iconPaperclip, iconSpinner, insertToTextArea, isImage, max_file_size, pasteText, project_uploads_path, showError, showSpinner, uploadFile, uploadProgress; - Dropzone.autoDiscover = false; - alertClass = "alert alert-danger alert-dismissable div-dropzone-alert"; - alertAttr = "class=\"close\" data-dismiss=\"alert\"" + "aria-hidden=\"true\""; - divHover = "
    "; - divSpinner = "
    "; - divAlert = "
    "; - iconPaperclip = ""; - iconSpinner = ""; - uploadProgress = $("
    "); - btnAlert = ""; - project_uploads_path = window.project_uploads_path || null; - max_file_size = gon.max_file_size || 10; - form_textarea = $(form).find(".js-gfm-input"); - form_textarea.wrap("
    "); - form_textarea.on('paste', (function(_this) { - return function(event) { - return handlePaste(event); - }; - })(this)); - $mdArea = $(form_textarea).closest('.md-area'); - $(form).setupMarkdownPreview(); - form_dropzone = $(form).find('.div-dropzone'); - form_dropzone.parent().addClass("div-dropzone-wrapper"); - form_dropzone.append(divHover); - form_dropzone.find(".div-dropzone-hover").append(iconPaperclip); - form_dropzone.append(divSpinner); - form_dropzone.find(".div-dropzone-spinner").append(iconSpinner); - form_dropzone.find(".div-dropzone-spinner").append(uploadProgress); - form_dropzone.find(".div-dropzone-spinner").css({ - "opacity": 0, - "display": "none" - }); - dropzone = form_dropzone.dropzone({ - url: project_uploads_path, - dictDefaultMessage: "", - clickable: true, - paramName: "file", - maxFilesize: max_file_size, - uploadMultiple: false, - headers: { - "X-CSRF-Token": $("meta[name=\"csrf-token\"]").attr("content") - }, - previewContainer: false, - processing: function() { - return $(".div-dropzone-alert").alert("close"); - }, - dragover: function() { - $mdArea.addClass('is-dropzone-hover'); - form.find(".div-dropzone-hover").css("opacity", 0.7); - }, - dragleave: function() { - $mdArea.removeClass('is-dropzone-hover'); - form.find(".div-dropzone-hover").css("opacity", 0); - }, - drop: function() { - $mdArea.removeClass('is-dropzone-hover'); - form.find(".div-dropzone-hover").css("opacity", 0); - form_textarea.focus(); - }, - success: function(header, response) { - pasteText(response.link.markdown); - }, - error: function(temp) { - var checkIfMsgExists, errorAlert; - errorAlert = $(form).find('.error-alert'); - checkIfMsgExists = errorAlert.children().length; - if (checkIfMsgExists === 0) { - errorAlert.append(divAlert); - $(".div-dropzone-alert").append(btnAlert + "Attaching the file failed."); - } - }, - totaluploadprogress: function(totalUploadProgress) { - uploadProgress.text(Math.round(totalUploadProgress) + "%"); - }, - sending: function() { - form_dropzone.find(".div-dropzone-spinner").css({ - "opacity": 0.7, - "display": "inherit" - }); - }, - queuecomplete: function() { - uploadProgress.text(""); - $(".dz-preview").remove(); - $(".markdown-area").trigger("input"); - $(".div-dropzone-spinner").css({ - "opacity": 0, - "display": "none" - }); - } - }); - child = $(dropzone[0]).children("textarea"); - handlePaste = function(event) { - var filename, image, pasteEvent, text; - pasteEvent = event.originalEvent; - if (pasteEvent.clipboardData && pasteEvent.clipboardData.items) { - image = isImage(pasteEvent); - if (image) { - event.preventDefault(); - filename = getFilename(pasteEvent) || "image.png"; - text = "{{" + filename + "}}"; - pasteText(text); - return uploadFile(image.getAsFile(), filename); - } - } +window.DropzoneInput = (function() { + function DropzoneInput(form) { + var $mdArea, alertAttr, alertClass, appendToTextArea, btnAlert, child, closeAlertMessage, closeSpinner, divAlert, divHover, divSpinner, dropzone, form_dropzone, form_textarea, getFilename, handlePaste, iconPaperclip, iconSpinner, insertToTextArea, isImage, max_file_size, pasteText, project_uploads_path, showError, showSpinner, uploadFile, uploadProgress; + Dropzone.autoDiscover = false; + alertClass = "alert alert-danger alert-dismissable div-dropzone-alert"; + alertAttr = "class=\"close\" data-dismiss=\"alert\"" + "aria-hidden=\"true\""; + divHover = "
    "; + divSpinner = "
    "; + divAlert = "
    "; + iconPaperclip = ""; + iconSpinner = ""; + uploadProgress = $("
    "); + btnAlert = ""; + project_uploads_path = window.project_uploads_path || null; + max_file_size = gon.max_file_size || 10; + form_textarea = $(form).find(".js-gfm-input"); + form_textarea.wrap("
    "); + form_textarea.on('paste', (function(_this) { + return function(event) { + return handlePaste(event); }; - isImage = function(data) { - var i, item; - i = 0; - while (i < data.clipboardData.items.length) { - item = data.clipboardData.items[i]; - if (item.type.indexOf("image") !== -1) { - return item; - } - i += 1; - } - return false; - }; - pasteText = function(text) { - var afterSelection, beforeSelection, caretEnd, caretStart, textEnd; - var formattedText = text + "\n\n"; - caretStart = $(child)[0].selectionStart; - caretEnd = $(child)[0].selectionEnd; - textEnd = $(child).val().length; - beforeSelection = $(child).val().substring(0, caretStart); - afterSelection = $(child).val().substring(caretEnd, textEnd); - $(child).val(beforeSelection + formattedText + afterSelection); - child.get(0).setSelectionRange(caretStart + formattedText.length, caretEnd + formattedText.length); - return form_textarea.trigger("input"); - }; - getFilename = function(e) { - var value; - if (window.clipboardData && window.clipboardData.getData) { - value = window.clipboardData.getData("Text"); - } else if (e.clipboardData && e.clipboardData.getData) { - value = e.clipboardData.getData("text/plain"); + })(this)); + $mdArea = $(form_textarea).closest('.md-area'); + $(form).setupMarkdownPreview(); + form_dropzone = $(form).find('.div-dropzone'); + form_dropzone.parent().addClass("div-dropzone-wrapper"); + form_dropzone.append(divHover); + form_dropzone.find(".div-dropzone-hover").append(iconPaperclip); + form_dropzone.append(divSpinner); + form_dropzone.find(".div-dropzone-spinner").append(iconSpinner); + form_dropzone.find(".div-dropzone-spinner").append(uploadProgress); + form_dropzone.find(".div-dropzone-spinner").css({ + "opacity": 0, + "display": "none" + }); + dropzone = form_dropzone.dropzone({ + url: project_uploads_path, + dictDefaultMessage: "", + clickable: true, + paramName: "file", + maxFilesize: max_file_size, + uploadMultiple: false, + headers: { + "X-CSRF-Token": $("meta[name=\"csrf-token\"]").attr("content") + }, + previewContainer: false, + processing: function() { + return $(".div-dropzone-alert").alert("close"); + }, + dragover: function() { + $mdArea.addClass('is-dropzone-hover'); + form.find(".div-dropzone-hover").css("opacity", 0.7); + }, + dragleave: function() { + $mdArea.removeClass('is-dropzone-hover'); + form.find(".div-dropzone-hover").css("opacity", 0); + }, + drop: function() { + $mdArea.removeClass('is-dropzone-hover'); + form.find(".div-dropzone-hover").css("opacity", 0); + form_textarea.focus(); + }, + success: function(header, response) { + pasteText(response.link.markdown); + }, + error: function(temp) { + var checkIfMsgExists, errorAlert; + errorAlert = $(form).find('.error-alert'); + checkIfMsgExists = errorAlert.children().length; + if (checkIfMsgExists === 0) { + errorAlert.append(divAlert); + $(".div-dropzone-alert").append(btnAlert + "Attaching the file failed."); } - value = value.split("\r"); - return value.first(); - }; - uploadFile = function(item, filename) { - var formData; - formData = new FormData(); - formData.append("file", item, filename); - return $.ajax({ - url: project_uploads_path, - type: "POST", - data: formData, - dataType: "json", - processData: false, - contentType: false, - headers: { - "X-CSRF-Token": $("meta[name=\"csrf-token\"]").attr("content") - }, - beforeSend: function() { - showSpinner(); - return closeAlertMessage(); - }, - success: function(e, textStatus, response) { - return insertToTextArea(filename, response.responseJSON.link.markdown); - }, - error: function(response) { - return showError(response.responseJSON.message); - }, - complete: function() { - return closeSpinner(); - } - }); - }; - insertToTextArea = function(filename, url) { - return $(child).val(function(index, val) { - return val.replace("{{" + filename + "}}", url + "\n"); - }); - }; - appendToTextArea = function(url) { - return $(child).val(function(index, val) { - return val + url + "\n"; - }); - }; - showSpinner = function(e) { - return form.find(".div-dropzone-spinner").css({ + }, + totaluploadprogress: function(totalUploadProgress) { + uploadProgress.text(Math.round(totalUploadProgress) + "%"); + }, + sending: function() { + form_dropzone.find(".div-dropzone-spinner").css({ "opacity": 0.7, "display": "inherit" }); - }; - closeSpinner = function() { - return form.find(".div-dropzone-spinner").css({ + }, + queuecomplete: function() { + uploadProgress.text(""); + $(".dz-preview").remove(); + $(".markdown-area").trigger("input"); + $(".div-dropzone-spinner").css({ "opacity": 0, "display": "none" }); - }; - showError = function(message) { - var checkIfMsgExists, errorAlert; - errorAlert = $(form).find('.error-alert'); - checkIfMsgExists = errorAlert.children().length; - if (checkIfMsgExists === 0) { - errorAlert.append(divAlert); - return $(".div-dropzone-alert").append(btnAlert + message); + } + }); + child = $(dropzone[0]).children("textarea"); + handlePaste = function(event) { + var filename, image, pasteEvent, text; + pasteEvent = event.originalEvent; + if (pasteEvent.clipboardData && pasteEvent.clipboardData.items) { + image = isImage(pasteEvent); + if (image) { + event.preventDefault(); + filename = getFilename(pasteEvent) || "image.png"; + text = "{{" + filename + "}}"; + pasteText(text); + return uploadFile(image.getAsFile(), filename); } - }; - closeAlertMessage = function() { - return form.find(".div-dropzone-alert").alert("close"); - }; - form.find(".markdown-selector").click(function(e) { - e.preventDefault(); - $(this).closest('.gfm-form').find('.div-dropzone').click(); + } + }; + isImage = function(data) { + var i, item; + i = 0; + while (i < data.clipboardData.items.length) { + item = data.clipboardData.items[i]; + if (item.type.indexOf("image") !== -1) { + return item; + } + i += 1; + } + return false; + }; + pasteText = function(text) { + var afterSelection, beforeSelection, caretEnd, caretStart, textEnd; + var formattedText = text + "\n\n"; + caretStart = $(child)[0].selectionStart; + caretEnd = $(child)[0].selectionEnd; + textEnd = $(child).val().length; + beforeSelection = $(child).val().substring(0, caretStart); + afterSelection = $(child).val().substring(caretEnd, textEnd); + $(child).val(beforeSelection + formattedText + afterSelection); + child.get(0).setSelectionRange(caretStart + formattedText.length, caretEnd + formattedText.length); + return form_textarea.trigger("input"); + }; + getFilename = function(e) { + var value; + if (window.clipboardData && window.clipboardData.getData) { + value = window.clipboardData.getData("Text"); + } else if (e.clipboardData && e.clipboardData.getData) { + value = e.clipboardData.getData("text/plain"); + } + value = value.split("\r"); + return value.first(); + }; + uploadFile = function(item, filename) { + var formData; + formData = new FormData(); + formData.append("file", item, filename); + return $.ajax({ + url: project_uploads_path, + type: "POST", + data: formData, + dataType: "json", + processData: false, + contentType: false, + headers: { + "X-CSRF-Token": $("meta[name=\"csrf-token\"]").attr("content") + }, + beforeSend: function() { + showSpinner(); + return closeAlertMessage(); + }, + success: function(e, textStatus, response) { + return insertToTextArea(filename, response.responseJSON.link.markdown); + }, + error: function(response) { + return showError(response.responseJSON.message); + }, + complete: function() { + return closeSpinner(); + } + }); + }; + insertToTextArea = function(filename, url) { + return $(child).val(function(index, val) { + return val.replace("{{" + filename + "}}", url + "\n"); + }); + }; + appendToTextArea = function(url) { + return $(child).val(function(index, val) { + return val + url + "\n"; + }); + }; + showSpinner = function(e) { + return form.find(".div-dropzone-spinner").css({ + "opacity": 0.7, + "display": "inherit" + }); + }; + closeSpinner = function() { + return form.find(".div-dropzone-spinner").css({ + "opacity": 0, + "display": "none" }); - } + }; + showError = function(message) { + var checkIfMsgExists, errorAlert; + errorAlert = $(form).find('.error-alert'); + checkIfMsgExists = errorAlert.children().length; + if (checkIfMsgExists === 0) { + errorAlert.append(divAlert); + return $(".div-dropzone-alert").append(btnAlert + message); + } + }; + closeAlertMessage = function() { + return form.find(".div-dropzone-alert").alert("close"); + }; + form.find(".markdown-selector").click(function(e) { + e.preventDefault(); + $(this).closest('.gfm-form').find('.div-dropzone').click(); + }); + } - return DropzoneInput; - })(); -}).call(window); + return DropzoneInput; +})(); diff --git a/app/assets/javascripts/due_date_select.js b/app/assets/javascripts/due_date_select.js index 9169fcd7328..fdbb4644971 100644 --- a/app/assets/javascripts/due_date_select.js +++ b/app/assets/javascripts/due_date_select.js @@ -2,203 +2,202 @@ /* global dateFormat */ /* global Pikaday */ -(function(global) { - class DueDateSelect { - constructor({ $dropdown, $loading } = {}) { - const $dropdownParent = $dropdown.closest('.dropdown'); - const $block = $dropdown.closest('.block'); - this.$loading = $loading; - this.$dropdown = $dropdown; - this.$dropdownParent = $dropdownParent; - this.$datePicker = $dropdownParent.find('.js-due-date-calendar'); - this.$block = $block; - this.$selectbox = $dropdown.closest('.selectbox'); - this.$value = $block.find('.value'); - this.$valueContent = $block.find('.value-content'); - this.$sidebarValue = $('.js-due-date-sidebar-value', $block); - this.fieldName = $dropdown.data('field-name'), - this.abilityName = $dropdown.data('ability-name'), - this.issueUpdateURL = $dropdown.data('issue-update'); - - this.rawSelectedDate = null; - this.displayedDate = null; - this.datePayload = null; - - this.initGlDropdown(); - this.initRemoveDueDate(); - this.initDatePicker(); - } - - initGlDropdown() { - this.$dropdown.glDropdown({ - opened: () => { - const calendar = this.$datePicker.data('pikaday'); - calendar.show(); - }, - hidden: () => { - this.$selectbox.hide(); - this.$value.css('display', ''); - } - }); - } - - initDatePicker() { - const $dueDateInput = $(`input[name='${this.fieldName}']`); - - const calendar = new Pikaday({ - field: $dueDateInput.get(0), - theme: 'gitlab-theme', - format: 'yyyy-mm-dd', - onSelect: (dateText) => { - const formattedDate = dateFormat(new Date(dateText), 'yyyy-mm-dd'); - - $dueDateInput.val(formattedDate); +class DueDateSelect { + constructor({ $dropdown, $loading } = {}) { + const $dropdownParent = $dropdown.closest('.dropdown'); + const $block = $dropdown.closest('.block'); + this.$loading = $loading; + this.$dropdown = $dropdown; + this.$dropdownParent = $dropdownParent; + this.$datePicker = $dropdownParent.find('.js-due-date-calendar'); + this.$block = $block; + this.$selectbox = $dropdown.closest('.selectbox'); + this.$value = $block.find('.value'); + this.$valueContent = $block.find('.value-content'); + this.$sidebarValue = $('.js-due-date-sidebar-value', $block); + this.fieldName = $dropdown.data('field-name'), + this.abilityName = $dropdown.data('ability-name'), + this.issueUpdateURL = $dropdown.data('issue-update'); + + this.rawSelectedDate = null; + this.displayedDate = null; + this.datePayload = null; + + this.initGlDropdown(); + this.initRemoveDueDate(); + this.initDatePicker(); + } - if (this.$dropdown.hasClass('js-issue-boards-due-date')) { - gl.issueBoards.BoardsStore.detail.issue.dueDate = $dueDateInput.val(); - this.updateIssueBoardIssue(); - } else { - this.saveDueDate(true); - } - } - }); + initGlDropdown() { + this.$dropdown.glDropdown({ + opened: () => { + const calendar = this.$datePicker.data('pikaday'); + calendar.show(); + }, + hidden: () => { + this.$selectbox.hide(); + this.$value.css('display', ''); + } + }); + } - calendar.setDate(new Date($dueDateInput.val())); - this.$datePicker.append(calendar.el); - this.$datePicker.data('pikaday', calendar); - } + initDatePicker() { + const $dueDateInput = $(`input[name='${this.fieldName}']`); - initRemoveDueDate() { - this.$block.on('click', '.js-remove-due-date', (e) => { - const calendar = this.$datePicker.data('pikaday'); - e.preventDefault(); + const calendar = new Pikaday({ + field: $dueDateInput.get(0), + theme: 'gitlab-theme', + format: 'yyyy-mm-dd', + onSelect: (dateText) => { + const formattedDate = dateFormat(new Date(dateText), 'yyyy-mm-dd'); - calendar.setDate(null); + $dueDateInput.val(formattedDate); if (this.$dropdown.hasClass('js-issue-boards-due-date')) { - gl.issueBoards.BoardsStore.detail.issue.dueDate = ''; + gl.issueBoards.BoardsStore.detail.issue.dueDate = $dueDateInput.val(); this.updateIssueBoardIssue(); } else { - $("input[name='" + this.fieldName + "']").val(''); - return this.saveDueDate(false); + this.saveDueDate(true); } - }); - } + } + }); - saveDueDate(isDropdown) { - this.parseSelectedDate(); - this.prepSelectedDate(); - this.submitSelectedDate(isDropdown); - } + calendar.setDate(new Date($dueDateInput.val())); + this.$datePicker.append(calendar.el); + this.$datePicker.data('pikaday', calendar); + } + + initRemoveDueDate() { + this.$block.on('click', '.js-remove-due-date', (e) => { + const calendar = this.$datePicker.data('pikaday'); + e.preventDefault(); - parseSelectedDate() { - this.rawSelectedDate = $(`input[name='${this.fieldName}']`).val(); + calendar.setDate(null); - if (this.rawSelectedDate.length) { - // Construct Date object manually to avoid buggy dateString support within Date constructor - const dateArray = this.rawSelectedDate.split('-').map(v => parseInt(v, 10)); - const dateObj = new Date(dateArray[0], dateArray[1] - 1, dateArray[2]); - this.displayedDate = dateFormat(dateObj, 'mmm d, yyyy'); + if (this.$dropdown.hasClass('js-issue-boards-due-date')) { + gl.issueBoards.BoardsStore.detail.issue.dueDate = ''; + this.updateIssueBoardIssue(); } else { - this.displayedDate = 'No due date'; + $("input[name='" + this.fieldName + "']").val(''); + return this.saveDueDate(false); } - } + }); + } - prepSelectedDate() { - const datePayload = {}; - datePayload[this.abilityName] = {}; - datePayload[this.abilityName].due_date = this.rawSelectedDate; - this.datePayload = datePayload; - } + saveDueDate(isDropdown) { + this.parseSelectedDate(); + this.prepSelectedDate(); + this.submitSelectedDate(isDropdown); + } - updateIssueBoardIssue () { - this.$loading.fadeIn(); - this.$dropdown.trigger('loading.gl.dropdown'); - this.$selectbox.hide(); - this.$value.css('display', ''); + parseSelectedDate() { + this.rawSelectedDate = $(`input[name='${this.fieldName}']`).val(); - gl.issueBoards.BoardsStore.detail.issue.update(this.$dropdown.attr('data-issue-update')) - .then(() => { - this.$loading.fadeOut(); - }); + if (this.rawSelectedDate.length) { + // Construct Date object manually to avoid buggy dateString support within Date constructor + const dateArray = this.rawSelectedDate.split('-').map(v => parseInt(v, 10)); + const dateObj = new Date(dateArray[0], dateArray[1] - 1, dateArray[2]); + this.displayedDate = dateFormat(dateObj, 'mmm d, yyyy'); + } else { + this.displayedDate = 'No due date'; } + } + + prepSelectedDate() { + const datePayload = {}; + datePayload[this.abilityName] = {}; + datePayload[this.abilityName].due_date = this.rawSelectedDate; + this.datePayload = datePayload; + } + + updateIssueBoardIssue () { + this.$loading.fadeIn(); + this.$dropdown.trigger('loading.gl.dropdown'); + this.$selectbox.hide(); + this.$value.css('display', ''); + + gl.issueBoards.BoardsStore.detail.issue.update(this.$dropdown.attr('data-issue-update')) + .then(() => { + this.$loading.fadeOut(); + }); + } + + submitSelectedDate(isDropdown) { + return $.ajax({ + type: 'PUT', + url: this.issueUpdateURL, + data: this.datePayload, + dataType: 'json', + beforeSend: () => { + const selectedDateValue = this.datePayload[this.abilityName].due_date; + const displayedDateStyle = this.displayedDate !== 'No due date' ? 'bold' : 'no-value'; + + this.$loading.fadeIn(); - submitSelectedDate(isDropdown) { - return $.ajax({ - type: 'PUT', - url: this.issueUpdateURL, - data: this.datePayload, - dataType: 'json', - beforeSend: () => { - const selectedDateValue = this.datePayload[this.abilityName].due_date; - const displayedDateStyle = this.displayedDate !== 'No due date' ? 'bold' : 'no-value'; - - this.$loading.fadeIn(); - - if (isDropdown) { - this.$dropdown.trigger('loading.gl.dropdown'); - this.$selectbox.hide(); - } - - this.$value.css('display', ''); - this.$valueContent.html(`${this.displayedDate}`); - this.$sidebarValue.html(this.displayedDate); - - return selectedDateValue.length ? - $('.js-remove-due-date-holder').removeClass('hidden') : - $('.js-remove-due-date-holder').addClass('hidden'); - } - }).done((data) => { if (isDropdown) { - this.$dropdown.trigger('loaded.gl.dropdown'); - this.$dropdown.dropdown('toggle'); + this.$dropdown.trigger('loading.gl.dropdown'); + this.$selectbox.hide(); } - return this.$loading.fadeOut(); - }); - } + + this.$value.css('display', ''); + this.$valueContent.html(`${this.displayedDate}`); + this.$sidebarValue.html(this.displayedDate); + + return selectedDateValue.length ? + $('.js-remove-due-date-holder').removeClass('hidden') : + $('.js-remove-due-date-holder').addClass('hidden'); + } + }).done((data) => { + if (isDropdown) { + this.$dropdown.trigger('loaded.gl.dropdown'); + this.$dropdown.dropdown('toggle'); + } + return this.$loading.fadeOut(); + }); } +} - class DueDateSelectors { - constructor() { - this.initMilestoneDatePicker(); - this.initIssuableSelect(); - } +class DueDateSelectors { + constructor() { + this.initMilestoneDatePicker(); + this.initIssuableSelect(); + } - initMilestoneDatePicker() { - $('.datepicker').each(function() { - const $datePicker = $(this); - const calendar = new Pikaday({ - field: $datePicker.get(0), - theme: 'gitlab-theme', - format: 'yyyy-mm-dd', - onSelect(dateText) { - $datePicker.val(dateFormat(new Date(dateText), 'yyyy-mm-dd')); - } - }); - calendar.setDate(new Date($datePicker.val())); - - $datePicker.data('pikaday', calendar); + initMilestoneDatePicker() { + $('.datepicker').each(function() { + const $datePicker = $(this); + const calendar = new Pikaday({ + field: $datePicker.get(0), + theme: 'gitlab-theme', + format: 'yyyy-mm-dd', + onSelect(dateText) { + $datePicker.val(dateFormat(new Date(dateText), 'yyyy-mm-dd')); + } }); + calendar.setDate(new Date($datePicker.val())); - $('.js-clear-due-date,.js-clear-start-date').on('click', (e) => { - e.preventDefault(); - const calendar = $(e.target).siblings('.datepicker').data('pikaday'); - calendar.setDate(null); - }); - } + $datePicker.data('pikaday', calendar); + }); - initIssuableSelect() { - const $loading = $('.js-issuable-update .due_date').find('.block-loading').hide(); + $('.js-clear-due-date,.js-clear-start-date').on('click', (e) => { + e.preventDefault(); + const calendar = $(e.target).siblings('.datepicker').data('pikaday'); + calendar.setDate(null); + }); + } + + initIssuableSelect() { + const $loading = $('.js-issuable-update .due_date').find('.block-loading').hide(); - $('.js-due-date-select').each((i, dropdown) => { - const $dropdown = $(dropdown); - new DueDateSelect({ - $dropdown, - $loading - }); + $('.js-due-date-select').each((i, dropdown) => { + const $dropdown = $(dropdown); + new DueDateSelect({ + $dropdown, + $loading }); - } + }); } +} - global.DueDateSelectors = DueDateSelectors; -})(window.gl || (window.gl = {})); +window.gl = window.gl || {}; +window.gl.DueDateSelectors = DueDateSelectors; diff --git a/app/assets/javascripts/files_comment_button.js b/app/assets/javascripts/files_comment_button.js index bf84f2a0a8f..3f041172ff3 100644 --- a/app/assets/javascripts/files_comment_button.js +++ b/app/assets/javascripts/files_comment_button.js @@ -2,142 +2,140 @@ /* global FilesCommentButton */ /* global notes */ -(function() { - let $commentButtonTemplate; - var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; +let $commentButtonTemplate; +var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; - this.FilesCommentButton = (function() { - var COMMENT_BUTTON_CLASS, EMPTY_CELL_CLASS, LINE_COLUMN_CLASSES, LINE_CONTENT_CLASS, LINE_HOLDER_CLASS, LINE_NUMBER_CLASS, OLD_LINE_CLASS, TEXT_FILE_SELECTOR, UNFOLDABLE_LINE_CLASS; +window.FilesCommentButton = (function() { + var COMMENT_BUTTON_CLASS, EMPTY_CELL_CLASS, LINE_COLUMN_CLASSES, LINE_CONTENT_CLASS, LINE_HOLDER_CLASS, LINE_NUMBER_CLASS, OLD_LINE_CLASS, TEXT_FILE_SELECTOR, UNFOLDABLE_LINE_CLASS; - COMMENT_BUTTON_CLASS = '.add-diff-note'; + COMMENT_BUTTON_CLASS = '.add-diff-note'; - LINE_HOLDER_CLASS = '.line_holder'; + LINE_HOLDER_CLASS = '.line_holder'; - LINE_NUMBER_CLASS = 'diff-line-num'; + LINE_NUMBER_CLASS = 'diff-line-num'; - LINE_CONTENT_CLASS = 'line_content'; + LINE_CONTENT_CLASS = 'line_content'; - UNFOLDABLE_LINE_CLASS = 'js-unfold'; + UNFOLDABLE_LINE_CLASS = 'js-unfold'; - EMPTY_CELL_CLASS = 'empty-cell'; + EMPTY_CELL_CLASS = 'empty-cell'; - OLD_LINE_CLASS = 'old_line'; + OLD_LINE_CLASS = 'old_line'; - LINE_COLUMN_CLASSES = "." + LINE_NUMBER_CLASS + ", .line_content"; + LINE_COLUMN_CLASSES = "." + LINE_NUMBER_CLASS + ", .line_content"; - TEXT_FILE_SELECTOR = '.text-file'; + TEXT_FILE_SELECTOR = '.text-file'; - function FilesCommentButton(filesContainerElement) { - this.render = bind(this.render, this); - this.hideButton = bind(this.hideButton, this); - this.isParallelView = notes.isParallelView(); - filesContainerElement.on('mouseover', LINE_COLUMN_CLASSES, this.render) - .on('mouseleave', LINE_COLUMN_CLASSES, this.hideButton); + function FilesCommentButton(filesContainerElement) { + this.render = bind(this.render, this); + this.hideButton = bind(this.hideButton, this); + this.isParallelView = notes.isParallelView(); + filesContainerElement.on('mouseover', LINE_COLUMN_CLASSES, this.render) + .on('mouseleave', LINE_COLUMN_CLASSES, this.hideButton); + } + + FilesCommentButton.prototype.render = function(e) { + var $currentTarget, buttonParentElement, lineContentElement, textFileElement, $button; + $currentTarget = $(e.currentTarget); + + if ($currentTarget.hasClass('js-no-comment-btn')) return; + + lineContentElement = this.getLineContent($currentTarget); + buttonParentElement = this.getButtonParent($currentTarget); + + if (!this.validateButtonParent(buttonParentElement) || !this.validateLineContent(lineContentElement)) return; + + $button = $(COMMENT_BUTTON_CLASS, buttonParentElement); + buttonParentElement.addClass('is-over') + .nextUntil(`.${LINE_CONTENT_CLASS}`).addClass('is-over'); + + if ($button.length) { + return; } - FilesCommentButton.prototype.render = function(e) { - var $currentTarget, buttonParentElement, lineContentElement, textFileElement, $button; - $currentTarget = $(e.currentTarget); + textFileElement = this.getTextFileElement($currentTarget); + buttonParentElement.append(this.buildButton({ + noteableType: textFileElement.attr('data-noteable-type'), + noteableID: textFileElement.attr('data-noteable-id'), + commitID: textFileElement.attr('data-commit-id'), + noteType: lineContentElement.attr('data-note-type'), + position: lineContentElement.attr('data-position'), + lineType: lineContentElement.attr('data-line-type'), + discussionID: lineContentElement.attr('data-discussion-id'), + lineCode: lineContentElement.attr('data-line-code') + })); + }; - if ($currentTarget.hasClass('js-no-comment-btn')) return; + FilesCommentButton.prototype.hideButton = function(e) { + var $currentTarget = $(e.currentTarget); + var buttonParentElement = this.getButtonParent($currentTarget); - lineContentElement = this.getLineContent($currentTarget); - buttonParentElement = this.getButtonParent($currentTarget); + buttonParentElement.removeClass('is-over') + .nextUntil(`.${LINE_CONTENT_CLASS}`).removeClass('is-over'); + }; - if (!this.validateButtonParent(buttonParentElement) || !this.validateLineContent(lineContentElement)) return; + FilesCommentButton.prototype.buildButton = function(buttonAttributes) { + return $commentButtonTemplate.clone().attr({ + 'data-noteable-type': buttonAttributes.noteableType, + 'data-noteable-id': buttonAttributes.noteableID, + 'data-commit-id': buttonAttributes.commitID, + 'data-note-type': buttonAttributes.noteType, + 'data-line-code': buttonAttributes.lineCode, + 'data-position': buttonAttributes.position, + 'data-discussion-id': buttonAttributes.discussionID, + 'data-line-type': buttonAttributes.lineType + }); + }; - $button = $(COMMENT_BUTTON_CLASS, buttonParentElement); - buttonParentElement.addClass('is-over') - .nextUntil(`.${LINE_CONTENT_CLASS}`).addClass('is-over'); + FilesCommentButton.prototype.getTextFileElement = function(hoveredElement) { + return hoveredElement.closest(TEXT_FILE_SELECTOR); + }; - if ($button.length) { - return; - } + FilesCommentButton.prototype.getLineContent = function(hoveredElement) { + if (hoveredElement.hasClass(LINE_CONTENT_CLASS)) { + return hoveredElement; + } + if (!this.isParallelView) { + return $(hoveredElement).closest(LINE_HOLDER_CLASS).find("." + LINE_CONTENT_CLASS); + } else { + return $(hoveredElement).next("." + LINE_CONTENT_CLASS); + } + }; - textFileElement = this.getTextFileElement($currentTarget); - buttonParentElement.append(this.buildButton({ - noteableType: textFileElement.attr('data-noteable-type'), - noteableID: textFileElement.attr('data-noteable-id'), - commitID: textFileElement.attr('data-commit-id'), - noteType: lineContentElement.attr('data-note-type'), - position: lineContentElement.attr('data-position'), - lineType: lineContentElement.attr('data-line-type'), - discussionID: lineContentElement.attr('data-discussion-id'), - lineCode: lineContentElement.attr('data-line-code') - })); - }; - - FilesCommentButton.prototype.hideButton = function(e) { - var $currentTarget = $(e.currentTarget); - var buttonParentElement = this.getButtonParent($currentTarget); - - buttonParentElement.removeClass('is-over') - .nextUntil(`.${LINE_CONTENT_CLASS}`).removeClass('is-over'); - }; - - FilesCommentButton.prototype.buildButton = function(buttonAttributes) { - return $commentButtonTemplate.clone().attr({ - 'data-noteable-type': buttonAttributes.noteableType, - 'data-noteable-id': buttonAttributes.noteableID, - 'data-commit-id': buttonAttributes.commitID, - 'data-note-type': buttonAttributes.noteType, - 'data-line-code': buttonAttributes.lineCode, - 'data-position': buttonAttributes.position, - 'data-discussion-id': buttonAttributes.discussionID, - 'data-line-type': buttonAttributes.lineType - }); - }; - - FilesCommentButton.prototype.getTextFileElement = function(hoveredElement) { - return hoveredElement.closest(TEXT_FILE_SELECTOR); - }; - - FilesCommentButton.prototype.getLineContent = function(hoveredElement) { - if (hoveredElement.hasClass(LINE_CONTENT_CLASS)) { + FilesCommentButton.prototype.getButtonParent = function(hoveredElement) { + if (!this.isParallelView) { + if (hoveredElement.hasClass(OLD_LINE_CLASS)) { return hoveredElement; } - if (!this.isParallelView) { - return $(hoveredElement).closest(LINE_HOLDER_CLASS).find("." + LINE_CONTENT_CLASS); - } else { - return $(hoveredElement).next("." + LINE_CONTENT_CLASS); - } - }; - - FilesCommentButton.prototype.getButtonParent = function(hoveredElement) { - if (!this.isParallelView) { - if (hoveredElement.hasClass(OLD_LINE_CLASS)) { - return hoveredElement; - } - return hoveredElement.parent().find("." + OLD_LINE_CLASS); - } else { - if (hoveredElement.hasClass(LINE_NUMBER_CLASS)) { - return hoveredElement; - } - return $(hoveredElement).prev("." + LINE_NUMBER_CLASS); + return hoveredElement.parent().find("." + OLD_LINE_CLASS); + } else { + if (hoveredElement.hasClass(LINE_NUMBER_CLASS)) { + return hoveredElement; } - }; + return $(hoveredElement).prev("." + LINE_NUMBER_CLASS); + } + }; - FilesCommentButton.prototype.validateButtonParent = function(buttonParentElement) { - return !buttonParentElement.hasClass(EMPTY_CELL_CLASS) && !buttonParentElement.hasClass(UNFOLDABLE_LINE_CLASS); - }; + FilesCommentButton.prototype.validateButtonParent = function(buttonParentElement) { + return !buttonParentElement.hasClass(EMPTY_CELL_CLASS) && !buttonParentElement.hasClass(UNFOLDABLE_LINE_CLASS); + }; - FilesCommentButton.prototype.validateLineContent = function(lineContentElement) { - return lineContentElement.attr('data-discussion-id') && lineContentElement.attr('data-discussion-id') !== ''; - }; + FilesCommentButton.prototype.validateLineContent = function(lineContentElement) { + return lineContentElement.attr('data-discussion-id') && lineContentElement.attr('data-discussion-id') !== ''; + }; - return FilesCommentButton; - })(); + return FilesCommentButton; +})(); - $.fn.filesCommentButton = function() { - $commentButtonTemplate = $(''); +$.fn.filesCommentButton = function() { + $commentButtonTemplate = $(''); - if (!(this && (this.parent().data('can-create-note') != null))) { - return; + if (!(this && (this.parent().data('can-create-note') != null))) { + return; + } + return this.each(function() { + if (!$.data(this, 'filesCommentButton')) { + return $.data(this, 'filesCommentButton', new FilesCommentButton($(this))); } - return this.each(function() { - if (!$.data(this, 'filesCommentButton')) { - return $.data(this, 'filesCommentButton', new FilesCommentButton($(this))); - } - }); - }; -}).call(window); + }); +}; diff --git a/app/assets/javascripts/filterable_list.js b/app/assets/javascripts/filterable_list.js index 47a40e28461..aaaeb9bddb1 100644 --- a/app/assets/javascripts/filterable_list.js +++ b/app/assets/javascripts/filterable_list.js @@ -2,6 +2,7 @@ * Makes search request for content when user types a value in the search input. * Updates the html content of the page with the received one. */ + export default class FilterableList { constructor(form, filter, holder) { this.filterForm = form; diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js index 730104b89f9..eec30624ff2 100644 --- a/app/assets/javascripts/flash.js +++ b/app/assets/javascripts/flash.js @@ -1,42 +1,41 @@ /* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, one-var, one-var-declaration-per-line, no-param-reassign, quotes, quote-props, prefer-template, comma-dangle, max-len */ -(function() { - this.Flash = (function() { - var hideFlash; - hideFlash = function() { - return $(this).fadeOut(); - }; +window.Flash = (function() { + var hideFlash; - function Flash(message, type, parent) { - var flash, textDiv; - if (type == null) { - type = 'alert'; - } - if (parent == null) { - parent = null; - } - if (parent) { - this.flashContainer = parent.find('.flash-container'); - } else { - this.flashContainer = $('.flash-container-page'); - } - this.flashContainer.html(''); - flash = $('
    ', { - "class": "flash-" + type - }); - flash.on('click', hideFlash); - textDiv = $('
    ', { - "class": 'flash-text', - text: message - }); - textDiv.appendTo(flash); - if (this.flashContainer.parent().hasClass('content-wrapper')) { - textDiv.addClass('container-fluid container-limited'); - } - flash.appendTo(this.flashContainer); - this.flashContainer.show(); + hideFlash = function() { + return $(this).fadeOut(); + }; + + function Flash(message, type, parent) { + var flash, textDiv; + if (type == null) { + type = 'alert'; + } + if (parent == null) { + parent = null; + } + if (parent) { + this.flashContainer = parent.find('.flash-container'); + } else { + this.flashContainer = $('.flash-container-page'); + } + this.flashContainer.html(''); + flash = $('
    ', { + "class": "flash-" + type + }); + flash.on('click', hideFlash); + textDiv = $('
    ', { + "class": 'flash-text', + text: message + }); + textDiv.appendTo(flash); + if (this.flashContainer.parent().hasClass('content-wrapper')) { + textDiv.addClass('container-fluid container-limited'); } + flash.appendTo(this.flashContainer); + this.flashContainer.show(); + } - return Flash; - })(); -}).call(window); + return Flash; +})(); diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index 4f7ce1fa197..9ac4c49d697 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -5,390 +5,386 @@ import emojiAliases from 'emojis/aliases.json'; import { glEmojiTag } from '~/behaviors/gl_emoji'; // Creates the variables for setting up GFM auto-completion -(function() { - if (window.gl == null) { - window.gl = {}; - } +window.gl = window.gl || {}; - function sanitize(str) { - return str.replace(/<(?:.|\n)*?>/gm, ''); - } +function sanitize(str) { + return str.replace(/<(?:.|\n)*?>/gm, ''); +} - window.gl.GfmAutoComplete = { - dataSources: {}, - defaultLoadingData: ['loading'], - cachedData: {}, - isLoadingData: {}, - atTypeMap: { - ':': 'emojis', - '@': 'members', - '#': 'issues', - '!': 'mergeRequests', - '~': 'labels', - '%': 'milestones', - '/': 'commands' - }, - // Emoji - Emoji: { - templateFunction: function(name) { - return `
  • - ${name} ${glEmojiTag(name)} -
  • - `; +window.gl.GfmAutoComplete = { + dataSources: {}, + defaultLoadingData: ['loading'], + cachedData: {}, + isLoadingData: {}, + atTypeMap: { + ':': 'emojis', + '@': 'members', + '#': 'issues', + '!': 'mergeRequests', + '~': 'labels', + '%': 'milestones', + '/': 'commands' + }, + // Emoji + Emoji: { + templateFunction: function(name) { + return `
  • + ${name} ${glEmojiTag(name)} +
  • + `; + } + }, + // Team Members + Members: { + template: '
  • ${avatarTag} ${username} ${title}
  • ' + }, + Labels: { + template: '
  • ${title}
  • ' + }, + // Issues and MergeRequests + Issues: { + template: '
  • ${id} ${title}
  • ' + }, + // Milestones + Milestones: { + template: '
  • ${title}
  • ' + }, + Loading: { + template: '
  • Loading...
  • ' + }, + DefaultOptions: { + sorter: function(query, items, searchKey) { + this.setting.highlightFirst = this.setting.alwaysHighlightFirst || query.length > 0; + if (gl.GfmAutoComplete.isLoading(items)) { + this.setting.highlightFirst = false; + return items; } + return $.fn.atwho["default"].callbacks.sorter(query, items, searchKey); }, - // Team Members - Members: { - template: '
  • ${avatarTag} ${username} ${title}
  • ' - }, - Labels: { - template: '
  • ${title}
  • ' - }, - // Issues and MergeRequests - Issues: { - template: '
  • ${id} ${title}
  • ' - }, - // Milestones - Milestones: { - template: '
  • ${title}
  • ' + filter: function(query, data, searchKey) { + if (gl.GfmAutoComplete.isLoading(data)) { + gl.GfmAutoComplete.fetchData(this.$inputor, this.at); + return data; + } else { + return $.fn.atwho["default"].callbacks.filter(query, data, searchKey); + } }, - Loading: { - template: '
  • Loading...
  • ' + beforeInsert: function(value) { + if (value && !this.setting.skipSpecialCharacterTest) { + var withoutAt = value.substring(1); + if (withoutAt && /[^\w\d]/.test(withoutAt)) value = value.charAt() + '"' + withoutAt + '"'; + } + return value; }, - DefaultOptions: { - sorter: function(query, items, searchKey) { - this.setting.highlightFirst = this.setting.alwaysHighlightFirst || query.length > 0; - if (gl.GfmAutoComplete.isLoading(items)) { - this.setting.highlightFirst = false; - return items; - } - return $.fn.atwho["default"].callbacks.sorter(query, items, searchKey); - }, - filter: function(query, data, searchKey) { - if (gl.GfmAutoComplete.isLoading(data)) { - gl.GfmAutoComplete.fetchData(this.$inputor, this.at); - return data; - } else { - return $.fn.atwho["default"].callbacks.filter(query, data, searchKey); - } - }, - beforeInsert: function(value) { - if (value && !this.setting.skipSpecialCharacterTest) { - var withoutAt = value.substring(1); - if (withoutAt && /[^\w\d]/.test(withoutAt)) value = value.charAt() + '"' + withoutAt + '"'; - } - return value; - }, - matcher: function (flag, subtext) { - // The below is taken from At.js source - // Tweaked to commands to start without a space only if char before is a non-word character - // https://github.com/ichord/At.js - var _a, _y, regexp, match, atSymbolsWithBar, atSymbolsWithoutBar; - atSymbolsWithBar = Object.keys(this.app.controllers).join('|'); - atSymbolsWithoutBar = Object.keys(this.app.controllers).join(''); - subtext = subtext.split(/\s+/g).pop(); - flag = flag.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&"); + matcher: function (flag, subtext) { + // The below is taken from At.js source + // Tweaked to commands to start without a space only if char before is a non-word character + // https://github.com/ichord/At.js + var _a, _y, regexp, match, atSymbolsWithBar, atSymbolsWithoutBar; + atSymbolsWithBar = Object.keys(this.app.controllers).join('|'); + atSymbolsWithoutBar = Object.keys(this.app.controllers).join(''); + subtext = subtext.split(/\s+/g).pop(); + flag = flag.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&"); - _a = decodeURI("%C3%80"); - _y = decodeURI("%C3%BF"); + _a = decodeURI("%C3%80"); + _y = decodeURI("%C3%BF"); - regexp = new RegExp("^(?:\\B|[^a-zA-Z0-9_" + atSymbolsWithoutBar + "]|\\s)" + flag + "(?!" + atSymbolsWithBar + ")((?:[A-Za-z" + _a + "-" + _y + "0-9_\'\.\+\-]|[^\\x00-\\x7a])*)$", 'gi'); + regexp = new RegExp("^(?:\\B|[^a-zA-Z0-9_" + atSymbolsWithoutBar + "]|\\s)" + flag + "(?!" + atSymbolsWithBar + ")((?:[A-Za-z" + _a + "-" + _y + "0-9_\'\.\+\-]|[^\\x00-\\x7a])*)$", 'gi'); - match = regexp.exec(subtext); + match = regexp.exec(subtext); - if (match) { - return match[1]; - } else { - return null; - } + if (match) { + return match[1]; + } else { + return null; } - }, - setup: function(input) { - // Add GFM auto-completion to all input fields, that accept GFM input. - this.input = input || $('.js-gfm-input'); - this.setupLifecycle(); - }, - setupLifecycle() { - this.input.each((i, input) => { - const $input = $(input); - $input.off('focus.setupAtWho').on('focus.setupAtWho', this.setupAtWho.bind(this, $input)); - // This triggers at.js again - // Needed for slash commands with suffixes (ex: /label ~) - $input.on('inserted-commands.atwho', $input.trigger.bind($input, 'keyup')); - }); - }, - setupAtWho: function($input) { - // Emoji - $input.atwho({ - at: ':', - displayTpl: function(value) { - return value && value.name ? this.Emoji.templateFunction(value.name) : this.Loading.template; - }.bind(this), - insertTpl: ':${name}:', - skipSpecialCharacterTest: true, - data: this.defaultLoadingData, - callbacks: { - sorter: this.DefaultOptions.sorter, - beforeInsert: this.DefaultOptions.beforeInsert, - filter: this.DefaultOptions.filter - } - }); - // Team Members - $input.atwho({ - at: '@', - displayTpl: function(value) { - return value.username != null ? this.Members.template : this.Loading.template; - }.bind(this), - insertTpl: '${atwho-at}${username}', - searchKey: 'search', - alwaysHighlightFirst: true, - skipSpecialCharacterTest: true, - data: this.defaultLoadingData, - callbacks: { - sorter: this.DefaultOptions.sorter, - filter: this.DefaultOptions.filter, - beforeInsert: this.DefaultOptions.beforeInsert, - matcher: this.DefaultOptions.matcher, - beforeSave: function(members) { - return $.map(members, function(m) { - let title = ''; - if (m.username == null) { - return m; - } - title = m.name; - if (m.count) { - title += " (" + m.count + ")"; - } + } + }, + setup: function(input) { + // Add GFM auto-completion to all input fields, that accept GFM input. + this.input = input || $('.js-gfm-input'); + this.setupLifecycle(); + }, + setupLifecycle() { + this.input.each((i, input) => { + const $input = $(input); + $input.off('focus.setupAtWho').on('focus.setupAtWho', this.setupAtWho.bind(this, $input)); + // This triggers at.js again + // Needed for slash commands with suffixes (ex: /label ~) + $input.on('inserted-commands.atwho', $input.trigger.bind($input, 'keyup')); + }); + }, + setupAtWho: function($input) { + // Emoji + $input.atwho({ + at: ':', + displayTpl: function(value) { + return value && value.name ? this.Emoji.templateFunction(value.name) : this.Loading.template; + }.bind(this), + insertTpl: ':${name}:', + skipSpecialCharacterTest: true, + data: this.defaultLoadingData, + callbacks: { + sorter: this.DefaultOptions.sorter, + beforeInsert: this.DefaultOptions.beforeInsert, + filter: this.DefaultOptions.filter + } + }); + // Team Members + $input.atwho({ + at: '@', + displayTpl: function(value) { + return value.username != null ? this.Members.template : this.Loading.template; + }.bind(this), + insertTpl: '${atwho-at}${username}', + searchKey: 'search', + alwaysHighlightFirst: true, + skipSpecialCharacterTest: true, + data: this.defaultLoadingData, + callbacks: { + sorter: this.DefaultOptions.sorter, + filter: this.DefaultOptions.filter, + beforeInsert: this.DefaultOptions.beforeInsert, + matcher: this.DefaultOptions.matcher, + beforeSave: function(members) { + return $.map(members, function(m) { + let title = ''; + if (m.username == null) { + return m; + } + title = m.name; + if (m.count) { + title += " (" + m.count + ")"; + } - const autoCompleteAvatar = m.avatar_url || m.username.charAt(0).toUpperCase(); - const imgAvatar = `${m.username}`; - const txtAvatar = `
    ${autoCompleteAvatar}
    `; + const autoCompleteAvatar = m.avatar_url || m.username.charAt(0).toUpperCase(); + const imgAvatar = `${m.username}`; + const txtAvatar = `
    ${autoCompleteAvatar}
    `; - return { - username: m.username, - avatarTag: autoCompleteAvatar.length === 1 ? txtAvatar : imgAvatar, - title: sanitize(title), - search: sanitize(m.username + " " + m.name) - }; - }); - } + return { + username: m.username, + avatarTag: autoCompleteAvatar.length === 1 ? txtAvatar : imgAvatar, + title: sanitize(title), + search: sanitize(m.username + " " + m.name) + }; + }); } - }); - $input.atwho({ - at: '#', - alias: 'issues', - searchKey: 'search', - displayTpl: function(value) { - return value.title != null ? this.Issues.template : this.Loading.template; - }.bind(this), - data: this.defaultLoadingData, - insertTpl: '${atwho-at}${id}', - callbacks: { - sorter: this.DefaultOptions.sorter, - filter: this.DefaultOptions.filter, - beforeInsert: this.DefaultOptions.beforeInsert, - matcher: this.DefaultOptions.matcher, - beforeSave: function(issues) { - return $.map(issues, function(i) { - if (i.title == null) { - return i; - } - return { - id: i.iid, - title: sanitize(i.title), - search: i.iid + " " + i.title - }; - }); - } + } + }); + $input.atwho({ + at: '#', + alias: 'issues', + searchKey: 'search', + displayTpl: function(value) { + return value.title != null ? this.Issues.template : this.Loading.template; + }.bind(this), + data: this.defaultLoadingData, + insertTpl: '${atwho-at}${id}', + callbacks: { + sorter: this.DefaultOptions.sorter, + filter: this.DefaultOptions.filter, + beforeInsert: this.DefaultOptions.beforeInsert, + matcher: this.DefaultOptions.matcher, + beforeSave: function(issues) { + return $.map(issues, function(i) { + if (i.title == null) { + return i; + } + return { + id: i.iid, + title: sanitize(i.title), + search: i.iid + " " + i.title + }; + }); } - }); - $input.atwho({ - at: '%', - alias: 'milestones', - searchKey: 'search', - insertTpl: '${atwho-at}${title}', - displayTpl: function(value) { - return value.title != null ? this.Milestones.template : this.Loading.template; - }.bind(this), - data: this.defaultLoadingData, - callbacks: { - matcher: this.DefaultOptions.matcher, - sorter: this.DefaultOptions.sorter, - beforeInsert: this.DefaultOptions.beforeInsert, - filter: this.DefaultOptions.filter, - beforeSave: function(milestones) { - return $.map(milestones, function(m) { - if (m.title == null) { - return m; - } - return { - id: m.iid, - title: sanitize(m.title), - search: "" + m.title - }; - }); - } + } + }); + $input.atwho({ + at: '%', + alias: 'milestones', + searchKey: 'search', + insertTpl: '${atwho-at}${title}', + displayTpl: function(value) { + return value.title != null ? this.Milestones.template : this.Loading.template; + }.bind(this), + data: this.defaultLoadingData, + callbacks: { + matcher: this.DefaultOptions.matcher, + sorter: this.DefaultOptions.sorter, + beforeInsert: this.DefaultOptions.beforeInsert, + filter: this.DefaultOptions.filter, + beforeSave: function(milestones) { + return $.map(milestones, function(m) { + if (m.title == null) { + return m; + } + return { + id: m.iid, + title: sanitize(m.title), + search: "" + m.title + }; + }); } - }); - $input.atwho({ - at: '!', - alias: 'mergerequests', - searchKey: 'search', - displayTpl: function(value) { - return value.title != null ? this.Issues.template : this.Loading.template; - }.bind(this), - data: this.defaultLoadingData, - insertTpl: '${atwho-at}${id}', - callbacks: { - sorter: this.DefaultOptions.sorter, - filter: this.DefaultOptions.filter, - beforeInsert: this.DefaultOptions.beforeInsert, - matcher: this.DefaultOptions.matcher, - beforeSave: function(merges) { - return $.map(merges, function(m) { - if (m.title == null) { - return m; - } - return { - id: m.iid, - title: sanitize(m.title), - search: m.iid + " " + m.title - }; - }); - } + } + }); + $input.atwho({ + at: '!', + alias: 'mergerequests', + searchKey: 'search', + displayTpl: function(value) { + return value.title != null ? this.Issues.template : this.Loading.template; + }.bind(this), + data: this.defaultLoadingData, + insertTpl: '${atwho-at}${id}', + callbacks: { + sorter: this.DefaultOptions.sorter, + filter: this.DefaultOptions.filter, + beforeInsert: this.DefaultOptions.beforeInsert, + matcher: this.DefaultOptions.matcher, + beforeSave: function(merges) { + return $.map(merges, function(m) { + if (m.title == null) { + return m; + } + return { + id: m.iid, + title: sanitize(m.title), + search: m.iid + " " + m.title + }; + }); } - }); - $input.atwho({ - at: '~', - alias: 'labels', - searchKey: 'search', - data: this.defaultLoadingData, - displayTpl: function(value) { - return this.isLoading(value) ? this.Loading.template : this.Labels.template; - }.bind(this), - insertTpl: '${atwho-at}${title}', - callbacks: { - matcher: this.DefaultOptions.matcher, - beforeInsert: this.DefaultOptions.beforeInsert, - filter: this.DefaultOptions.filter, - sorter: this.DefaultOptions.sorter, - beforeSave: function(merges) { - if (gl.GfmAutoComplete.isLoading(merges)) return merges; - var sanitizeLabelTitle; - sanitizeLabelTitle = function(title) { - if (/[\w\?&]+\s+[\w\?&]+/g.test(title)) { - return "\"" + (sanitize(title)) + "\""; - } else { - return sanitize(title); - } + } + }); + $input.atwho({ + at: '~', + alias: 'labels', + searchKey: 'search', + data: this.defaultLoadingData, + displayTpl: function(value) { + return this.isLoading(value) ? this.Loading.template : this.Labels.template; + }.bind(this), + insertTpl: '${atwho-at}${title}', + callbacks: { + matcher: this.DefaultOptions.matcher, + beforeInsert: this.DefaultOptions.beforeInsert, + filter: this.DefaultOptions.filter, + sorter: this.DefaultOptions.sorter, + beforeSave: function(merges) { + if (gl.GfmAutoComplete.isLoading(merges)) return merges; + var sanitizeLabelTitle; + sanitizeLabelTitle = function(title) { + if (/[\w\?&]+\s+[\w\?&]+/g.test(title)) { + return "\"" + (sanitize(title)) + "\""; + } else { + return sanitize(title); + } + }; + return $.map(merges, function(m) { + return { + title: sanitize(m.title), + color: m.color, + search: "" + m.title }; - return $.map(merges, function(m) { - return { - title: sanitize(m.title), - color: m.color, - search: "" + m.title - }; - }); - } + }); } - }); - // We don't instantiate the slash commands autocomplete for note and issue/MR edit forms - $input.filter('[data-supports-slash-commands="true"]').atwho({ - at: '/', - alias: 'commands', - searchKey: 'search', - skipSpecialCharacterTest: true, - data: this.defaultLoadingData, - displayTpl: function(value) { - if (this.isLoading(value)) return this.Loading.template; - var tpl = '
  • /${name}'; - if (value.aliases.length > 0) { - tpl += ' (or /<%- aliases.join(", /") %>)'; - } - if (value.params.length > 0) { - tpl += ' <%- params.join(" ") %>'; - } - if (value.description !== '') { - tpl += '<%- description %>'; + } + }); + // We don't instantiate the slash commands autocomplete for note and issue/MR edit forms + $input.filter('[data-supports-slash-commands="true"]').atwho({ + at: '/', + alias: 'commands', + searchKey: 'search', + skipSpecialCharacterTest: true, + data: this.defaultLoadingData, + displayTpl: function(value) { + if (this.isLoading(value)) return this.Loading.template; + var tpl = '
  • /${name}'; + if (value.aliases.length > 0) { + tpl += ' (or /<%- aliases.join(", /") %>)'; + } + if (value.params.length > 0) { + tpl += ' <%- params.join(" ") %>'; + } + if (value.description !== '') { + tpl += '<%- description %>'; + } + tpl += '
  • '; + return _.template(tpl)(value); + }.bind(this), + insertTpl: function(value) { + var tpl = "/${name} "; + var reference_prefix = null; + if (value.params.length > 0) { + reference_prefix = value.params[0][0]; + if (/^[@%~]/.test(reference_prefix)) { + tpl += '<%- reference_prefix %>'; } - tpl += ''; - return _.template(tpl)(value); - }.bind(this), - insertTpl: function(value) { - var tpl = "/${name} "; - var reference_prefix = null; - if (value.params.length > 0) { - reference_prefix = value.params[0][0]; - if (/^[@%~]/.test(reference_prefix)) { - tpl += '<%- reference_prefix %>'; + } + return _.template(tpl)({ reference_prefix: reference_prefix }); + }, + suffix: '', + callbacks: { + sorter: this.DefaultOptions.sorter, + filter: this.DefaultOptions.filter, + beforeInsert: this.DefaultOptions.beforeInsert, + beforeSave: function(commands) { + if (gl.GfmAutoComplete.isLoading(commands)) return commands; + return $.map(commands, function(c) { + var search = c.name; + if (c.aliases.length > 0) { + search = search + " " + c.aliases.join(" "); } - } - return _.template(tpl)({ reference_prefix: reference_prefix }); + return { + name: c.name, + aliases: c.aliases, + params: c.params, + description: c.description, + search: search + }; + }); }, - suffix: '', - callbacks: { - sorter: this.DefaultOptions.sorter, - filter: this.DefaultOptions.filter, - beforeInsert: this.DefaultOptions.beforeInsert, - beforeSave: function(commands) { - if (gl.GfmAutoComplete.isLoading(commands)) return commands; - return $.map(commands, function(c) { - var search = c.name; - if (c.aliases.length > 0) { - search = search + " " + c.aliases.join(" "); - } - return { - name: c.name, - aliases: c.aliases, - params: c.params, - description: c.description, - search: search - }; - }); - }, - matcher: function(flag, subtext, should_startWithSpace, acceptSpaceBar) { - var regexp = /(?:^|\n)\/([A-Za-z_]*)$/gi; - var match = regexp.exec(subtext); - if (match) { - return match[1]; - } else { - return null; - } + matcher: function(flag, subtext, should_startWithSpace, acceptSpaceBar) { + var regexp = /(?:^|\n)\/([A-Za-z_]*)$/gi; + var match = regexp.exec(subtext); + if (match) { + return match[1]; + } else { + return null; } } - }); - return; - }, - fetchData: function($input, at) { - if (this.isLoadingData[at]) return; - this.isLoadingData[at] = true; - if (this.cachedData[at]) { - this.loadData($input, at, this.cachedData[at]); - } else if (this.atTypeMap[at] === 'emojis') { - this.loadData($input, at, Object.keys(emojiMap).concat(Object.keys(emojiAliases))); - } else { - $.getJSON(this.dataSources[this.atTypeMap[at]], (data) => { - this.loadData($input, at, data); - }).fail(() => { this.isLoadingData[at] = false; }); - } - }, - loadData: function($input, at, data) { - this.isLoadingData[at] = false; - this.cachedData[at] = data; - $input.atwho('load', at, data); - // This trigger at.js again - // otherwise we would be stuck with loading until the user types - return $input.trigger('keyup'); - }, - isLoading(data) { - var dataToInspect = data; - if (data && data.length > 0) { - dataToInspect = data[0]; } - - var loadingState = this.defaultLoadingData[0]; - return dataToInspect && - (dataToInspect === loadingState || dataToInspect.name === loadingState); + }); + return; + }, + fetchData: function($input, at) { + if (this.isLoadingData[at]) return; + this.isLoadingData[at] = true; + if (this.cachedData[at]) { + this.loadData($input, at, this.cachedData[at]); + } else if (this.atTypeMap[at] === 'emojis') { + this.loadData($input, at, Object.keys(emojiMap).concat(Object.keys(emojiAliases))); + } else { + $.getJSON(this.dataSources[this.atTypeMap[at]], (data) => { + this.loadData($input, at, data); + }).fail(() => { this.isLoadingData[at] = false; }); + } + }, + loadData: function($input, at, data) { + this.isLoadingData[at] = false; + this.cachedData[at] = data; + $input.atwho('load', at, data); + // This trigger at.js again + // otherwise we would be stuck with loading until the user types + return $input.trigger('keyup'); + }, + isLoading(data) { + var dataToInspect = data; + if (data && data.length > 0) { + dataToInspect = data[0]; } - }; -}).call(window); + + var loadingState = this.defaultLoadingData[0]; + return dataToInspect && + (dataToInspect === loadingState || dataToInspect.name === loadingState); + } +}; diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js index 9e6ed06054b..a03f1202a6d 100644 --- a/app/assets/javascripts/gl_dropdown.js +++ b/app/assets/javascripts/gl_dropdown.js @@ -1,850 +1,848 @@ /* eslint-disable func-names, space-before-function-paren, no-var, one-var, one-var-declaration-per-line, prefer-rest-params, max-len, vars-on-top, wrap-iife, no-unused-vars, quotes, no-shadow, no-cond-assign, prefer-arrow-callback, no-return-assign, no-else-return, camelcase, comma-dangle, no-lonely-if, guard-for-in, no-restricted-syntax, consistent-return, prefer-template, no-param-reassign, no-loop-func, no-mixed-operators */ /* global fuzzaldrinPlus */ -(function() { - var GitLabDropdown, GitLabDropdownFilter, GitLabDropdownRemote, - bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }, - indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i += 1) { if (i in this && this[i] === item) return i; } return -1; }; - - GitLabDropdownFilter = (function() { - var ARROW_KEY_CODES, BLUR_KEYCODES, HAS_VALUE_CLASS; - - BLUR_KEYCODES = [27, 40]; - - ARROW_KEY_CODES = [38, 40]; - - HAS_VALUE_CLASS = "has-value"; - - function GitLabDropdownFilter(input, options) { - var $clearButton, $inputContainer, ref, timeout; - this.input = input; - this.options = options; - this.filterInputBlur = (ref = this.options.filterInputBlur) != null ? ref : true; - $inputContainer = this.input.parent(); - $clearButton = $inputContainer.find('.js-dropdown-input-clear'); - $clearButton.on('click', (function(_this) { - // Clear click - return function(e) { +var GitLabDropdown, GitLabDropdownFilter, GitLabDropdownRemote, + bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }, + indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i += 1) { if (i in this && this[i] === item) return i; } return -1; }; + +GitLabDropdownFilter = (function() { + var ARROW_KEY_CODES, BLUR_KEYCODES, HAS_VALUE_CLASS; + + BLUR_KEYCODES = [27, 40]; + + ARROW_KEY_CODES = [38, 40]; + + HAS_VALUE_CLASS = "has-value"; + + function GitLabDropdownFilter(input, options) { + var $clearButton, $inputContainer, ref, timeout; + this.input = input; + this.options = options; + this.filterInputBlur = (ref = this.options.filterInputBlur) != null ? ref : true; + $inputContainer = this.input.parent(); + $clearButton = $inputContainer.find('.js-dropdown-input-clear'); + $clearButton.on('click', (function(_this) { + // Clear click + return function(e) { + e.preventDefault(); + e.stopPropagation(); + return _this.input.val('').trigger('input').focus(); + }; + })(this)); + // Key events + timeout = ""; + this.input + .on('keydown', function (e) { + var keyCode = e.which; + if (keyCode === 13 && !options.elIsInput) { e.preventDefault(); - e.stopPropagation(); - return _this.input.val('').trigger('input').focus(); - }; - })(this)); - // Key events - timeout = ""; - this.input - .on('keydown', function (e) { - var keyCode = e.which; - if (keyCode === 13 && !options.elIsInput) { - e.preventDefault(); - } - }) - .on('input', function() { - if (this.input.val() !== "" && !$inputContainer.hasClass(HAS_VALUE_CLASS)) { - $inputContainer.addClass(HAS_VALUE_CLASS); - } else if (this.input.val() === "" && $inputContainer.hasClass(HAS_VALUE_CLASS)) { - $inputContainer.removeClass(HAS_VALUE_CLASS); - } - // Only filter asynchronously only if option remote is set - if (this.options.remote) { - clearTimeout(timeout); - return timeout = setTimeout(function() { - $inputContainer.parent().addClass('is-loading'); - - return this.options.query(this.input.val(), function(data) { - $inputContainer.parent().removeClass('is-loading'); - return this.options.callback(data); - }.bind(this)); - }.bind(this), 250); - } else { - return this.filter(this.input.val()); - } - }.bind(this)); - } + } + }) + .on('input', function() { + if (this.input.val() !== "" && !$inputContainer.hasClass(HAS_VALUE_CLASS)) { + $inputContainer.addClass(HAS_VALUE_CLASS); + } else if (this.input.val() === "" && $inputContainer.hasClass(HAS_VALUE_CLASS)) { + $inputContainer.removeClass(HAS_VALUE_CLASS); + } + // Only filter asynchronously only if option remote is set + if (this.options.remote) { + clearTimeout(timeout); + return timeout = setTimeout(function() { + $inputContainer.parent().addClass('is-loading'); + + return this.options.query(this.input.val(), function(data) { + $inputContainer.parent().removeClass('is-loading'); + return this.options.callback(data); + }.bind(this)); + }.bind(this), 250); + } else { + return this.filter(this.input.val()); + } + }.bind(this)); + } - GitLabDropdownFilter.prototype.shouldBlur = function(keyCode) { - return BLUR_KEYCODES.indexOf(keyCode) !== -1; - }; + GitLabDropdownFilter.prototype.shouldBlur = function(keyCode) { + return BLUR_KEYCODES.indexOf(keyCode) !== -1; + }; - GitLabDropdownFilter.prototype.filter = function(search_text) { - var data, elements, group, key, results, tmp; - if (this.options.onFilter) { - this.options.onFilter(search_text); - } - data = this.options.data(); - if ((data != null) && !this.options.filterByText) { - results = data; - if (search_text !== '') { - // When data is an array of objects therefore [object Array] e.g. - // [ - // { prop: 'foo' }, - // { prop: 'baz' } - // ] - if (_.isArray(data)) { - results = fuzzaldrinPlus.filter(data, search_text, { - key: this.options.keys - }); - } else { - // If data is grouped therefore an [object Object]. e.g. - // { - // groupName1: [ - // { prop: 'foo' }, - // { prop: 'baz' } - // ], - // groupName2: [ - // { prop: 'abc' }, - // { prop: 'def' } - // ] - // } - if (gl.utils.isObject(data)) { - results = {}; - for (key in data) { - group = data[key]; - tmp = fuzzaldrinPlus.filter(group, search_text, { - key: this.options.keys + GitLabDropdownFilter.prototype.filter = function(search_text) { + var data, elements, group, key, results, tmp; + if (this.options.onFilter) { + this.options.onFilter(search_text); + } + data = this.options.data(); + if ((data != null) && !this.options.filterByText) { + results = data; + if (search_text !== '') { + // When data is an array of objects therefore [object Array] e.g. + // [ + // { prop: 'foo' }, + // { prop: 'baz' } + // ] + if (_.isArray(data)) { + results = fuzzaldrinPlus.filter(data, search_text, { + key: this.options.keys + }); + } else { + // If data is grouped therefore an [object Object]. e.g. + // { + // groupName1: [ + // { prop: 'foo' }, + // { prop: 'baz' } + // ], + // groupName2: [ + // { prop: 'abc' }, + // { prop: 'def' } + // ] + // } + if (gl.utils.isObject(data)) { + results = {}; + for (key in data) { + group = data[key]; + tmp = fuzzaldrinPlus.filter(group, search_text, { + key: this.options.keys + }); + if (tmp.length) { + results[key] = tmp.map(function(item) { + return item; }); - if (tmp.length) { - results[key] = tmp.map(function(item) { - return item; - }); - } } } } } - return this.options.callback(results); - } else { - elements = this.options.elements(); - if (search_text) { - return elements.each(function() { - var $el, matches; - $el = $(this); - matches = fuzzaldrinPlus.match($el.text().trim(), search_text); - if (!$el.is('.dropdown-header')) { - if (matches.length) { - return $el.show().removeClass('option-hidden'); - } else { - return $el.hide().addClass('option-hidden'); - } + } + return this.options.callback(results); + } else { + elements = this.options.elements(); + if (search_text) { + return elements.each(function() { + var $el, matches; + $el = $(this); + matches = fuzzaldrinPlus.match($el.text().trim(), search_text); + if (!$el.is('.dropdown-header')) { + if (matches.length) { + return $el.show().removeClass('option-hidden'); + } else { + return $el.hide().addClass('option-hidden'); } - }); - } else { - return elements.show().removeClass('option-hidden'); - } + } + }); + } else { + return elements.show().removeClass('option-hidden'); } - }; - - return GitLabDropdownFilter; - })(); + } + }; - GitLabDropdownRemote = (function() { - function GitLabDropdownRemote(dataEndpoint, options) { - this.dataEndpoint = dataEndpoint; - this.options = options; + return GitLabDropdownFilter; +})(); + +GitLabDropdownRemote = (function() { + function GitLabDropdownRemote(dataEndpoint, options) { + this.dataEndpoint = dataEndpoint; + this.options = options; + } + + GitLabDropdownRemote.prototype.execute = function() { + if (typeof this.dataEndpoint === "string") { + return this.fetchData(); + } else if (typeof this.dataEndpoint === "function") { + if (this.options.beforeSend) { + this.options.beforeSend(); + } + return this.dataEndpoint("", (function(_this) { + // Fetch the data by calling the data funcfion + return function(data) { + if (_this.options.success) { + _this.options.success(data); + } + if (_this.options.beforeSend) { + return _this.options.beforeSend(); + } + }; + })(this)); } + }; - GitLabDropdownRemote.prototype.execute = function() { - if (typeof this.dataEndpoint === "string") { - return this.fetchData(); - } else if (typeof this.dataEndpoint === "function") { - if (this.options.beforeSend) { - this.options.beforeSend(); - } - return this.dataEndpoint("", (function(_this) { - // Fetch the data by calling the data funcfion - return function(data) { - if (_this.options.success) { - _this.options.success(data); - } - if (_this.options.beforeSend) { - return _this.options.beforeSend(); - } - }; - })(this)); - } - }; + GitLabDropdownRemote.prototype.fetchData = function() { + return $.ajax({ + url: this.dataEndpoint, + dataType: this.options.dataType, + beforeSend: (function(_this) { + return function() { + if (_this.options.beforeSend) { + return _this.options.beforeSend(); + } + }; + })(this), + success: (function(_this) { + return function(data) { + if (_this.options.success) { + return _this.options.success(data); + } + }; + })(this) + }); + // Fetch the data through ajax if the data is a string + }; - GitLabDropdownRemote.prototype.fetchData = function() { - return $.ajax({ - url: this.dataEndpoint, - dataType: this.options.dataType, - beforeSend: (function(_this) { - return function() { - if (_this.options.beforeSend) { - return _this.options.beforeSend(); - } - }; - })(this), - success: (function(_this) { - return function(data) { - if (_this.options.success) { - return _this.options.success(data); - } - }; - })(this) - }); - // Fetch the data through ajax if the data is a string - }; + return GitLabDropdownRemote; +})(); - return GitLabDropdownRemote; - })(); +GitLabDropdown = (function() { + var ACTIVE_CLASS, FILTER_INPUT, INDETERMINATE_CLASS, LOADING_CLASS, PAGE_TWO_CLASS, NON_SELECTABLE_CLASSES, SELECTABLE_CLASSES, CURSOR_SELECT_SCROLL_PADDING, currentIndex; - GitLabDropdown = (function() { - var ACTIVE_CLASS, FILTER_INPUT, INDETERMINATE_CLASS, LOADING_CLASS, PAGE_TWO_CLASS, NON_SELECTABLE_CLASSES, SELECTABLE_CLASSES, CURSOR_SELECT_SCROLL_PADDING, currentIndex; + LOADING_CLASS = "is-loading"; - LOADING_CLASS = "is-loading"; + PAGE_TWO_CLASS = "is-page-two"; - PAGE_TWO_CLASS = "is-page-two"; + ACTIVE_CLASS = "is-active"; - ACTIVE_CLASS = "is-active"; + INDETERMINATE_CLASS = "is-indeterminate"; - INDETERMINATE_CLASS = "is-indeterminate"; + currentIndex = -1; - currentIndex = -1; + NON_SELECTABLE_CLASSES = '.divider, .separator, .dropdown-header, .dropdown-menu-empty-link'; - NON_SELECTABLE_CLASSES = '.divider, .separator, .dropdown-header, .dropdown-menu-empty-link'; - - SELECTABLE_CLASSES = ".dropdown-content li:not(" + NON_SELECTABLE_CLASSES + ", .option-hidden)"; - - CURSOR_SELECT_SCROLL_PADDING = 5; - - FILTER_INPUT = '.dropdown-input .dropdown-input-field'; - - function GitLabDropdown(el1, options) { - var searchFields, selector, self; - this.el = el1; - this.options = options; - this.updateLabel = bind(this.updateLabel, this); - this.hidden = bind(this.hidden, this); - this.opened = bind(this.opened, this); - this.shouldPropagate = bind(this.shouldPropagate, this); - self = this; - selector = $(this.el).data("target"); - this.dropdown = selector != null ? $(selector) : $(this.el).parent(); - // Set Defaults - this.filterInput = this.options.filterInput || this.getElement(FILTER_INPUT); - this.highlight = !!this.options.highlight; - this.filterInputBlur = this.options.filterInputBlur != null - ? this.options.filterInputBlur - : true; - // If no input is passed create a default one - self = this; - // If selector was passed - if (_.isString(this.filterInput)) { - this.filterInput = this.getElement(this.filterInput); - } - searchFields = this.options.search ? this.options.search.fields : []; - if (this.options.data) { - // If we provided data - // data could be an array of objects or a group of arrays - if (_.isObject(this.options.data) && !_.isFunction(this.options.data)) { - this.fullData = this.options.data; - currentIndex = -1; - this.parseData(this.options.data); - this.focusTextInput(); - } else { - this.remote = new GitLabDropdownRemote(this.options.data, { - dataType: this.options.dataType, - beforeSend: this.toggleLoading.bind(this), - success: (function(_this) { - return function(data) { - _this.fullData = data; - _this.parseData(_this.fullData); - _this.focusTextInput(); - if (_this.options.filterable && _this.filter && _this.filter.input && _this.filter.input.val() && _this.filter.input.val().trim() !== '') { - return _this.filter.input.trigger('input'); - } - }; - // Remote data - })(this) - }); - } - } - // Init filterable - if (this.options.filterable) { - this.filter = new GitLabDropdownFilter(this.filterInput, { - elIsInput: $(this.el).is('input'), - filterInputBlur: this.filterInputBlur, - filterByText: this.options.filterByText, - onFilter: this.options.onFilter, - remote: this.options.filterRemote, - query: this.options.data, - keys: searchFields, - elements: (function(_this) { - return function() { - selector = '.dropdown-content li:not(' + NON_SELECTABLE_CLASSES + ')'; - if (_this.dropdown.find('.dropdown-toggle-page').length) { - selector = ".dropdown-page-one " + selector; - } - return $(selector); - }; - })(this), - data: (function(_this) { - return function() { - return _this.fullData; - }; - })(this), - callback: (function(_this) { + SELECTABLE_CLASSES = ".dropdown-content li:not(" + NON_SELECTABLE_CLASSES + ", .option-hidden)"; + + CURSOR_SELECT_SCROLL_PADDING = 5; + + FILTER_INPUT = '.dropdown-input .dropdown-input-field'; + + function GitLabDropdown(el1, options) { + var searchFields, selector, self; + this.el = el1; + this.options = options; + this.updateLabel = bind(this.updateLabel, this); + this.hidden = bind(this.hidden, this); + this.opened = bind(this.opened, this); + this.shouldPropagate = bind(this.shouldPropagate, this); + self = this; + selector = $(this.el).data("target"); + this.dropdown = selector != null ? $(selector) : $(this.el).parent(); + // Set Defaults + this.filterInput = this.options.filterInput || this.getElement(FILTER_INPUT); + this.highlight = !!this.options.highlight; + this.filterInputBlur = this.options.filterInputBlur != null + ? this.options.filterInputBlur + : true; + // If no input is passed create a default one + self = this; + // If selector was passed + if (_.isString(this.filterInput)) { + this.filterInput = this.getElement(this.filterInput); + } + searchFields = this.options.search ? this.options.search.fields : []; + if (this.options.data) { + // If we provided data + // data could be an array of objects or a group of arrays + if (_.isObject(this.options.data) && !_.isFunction(this.options.data)) { + this.fullData = this.options.data; + currentIndex = -1; + this.parseData(this.options.data); + this.focusTextInput(); + } else { + this.remote = new GitLabDropdownRemote(this.options.data, { + dataType: this.options.dataType, + beforeSend: this.toggleLoading.bind(this), + success: (function(_this) { return function(data) { - _this.parseData(data); - if (_this.filterInput.val() !== '') { - selector = SELECTABLE_CLASSES; - if (_this.dropdown.find('.dropdown-toggle-page').length) { - selector = ".dropdown-page-one " + selector; - } - if ($(_this.el).is('input')) { - currentIndex = -1; - } else { - $(selector, _this.dropdown).first().find('a').addClass('is-focused'); - currentIndex = 0; - } + _this.fullData = data; + _this.parseData(_this.fullData); + _this.focusTextInput(); + if (_this.options.filterable && _this.filter && _this.filter.input && _this.filter.input.val() && _this.filter.input.val().trim() !== '') { + return _this.filter.input.trigger('input'); } }; + // Remote data })(this) }); } - // Event listeners - this.dropdown.on("shown.bs.dropdown", this.opened); - this.dropdown.on("hidden.bs.dropdown", this.hidden); - $(this.el).on("update.label", this.updateLabel); - this.dropdown.on("click", ".dropdown-menu, .dropdown-menu-close", this.shouldPropagate); - this.dropdown.on('keyup', (function(_this) { - return function(e) { - // Escape key - if (e.which === 27) { - return $('.dropdown-menu-close', _this.dropdown).trigger('click'); - } - }; - })(this)); - this.dropdown.on('blur', 'a', (function(_this) { - return function(e) { - var $dropdownMenu, $relatedTarget; - if (e.relatedTarget != null) { - $relatedTarget = $(e.relatedTarget); - $dropdownMenu = $relatedTarget.closest('.dropdown-menu'); - if ($dropdownMenu.length === 0) { - return _this.dropdown.removeClass('open'); + } + // Init filterable + if (this.options.filterable) { + this.filter = new GitLabDropdownFilter(this.filterInput, { + elIsInput: $(this.el).is('input'), + filterInputBlur: this.filterInputBlur, + filterByText: this.options.filterByText, + onFilter: this.options.onFilter, + remote: this.options.filterRemote, + query: this.options.data, + keys: searchFields, + elements: (function(_this) { + return function() { + selector = '.dropdown-content li:not(' + NON_SELECTABLE_CLASSES + ')'; + if (_this.dropdown.find('.dropdown-toggle-page').length) { + selector = ".dropdown-page-one " + selector; + } + return $(selector); + }; + })(this), + data: (function(_this) { + return function() { + return _this.fullData; + }; + })(this), + callback: (function(_this) { + return function(data) { + _this.parseData(data); + if (_this.filterInput.val() !== '') { + selector = SELECTABLE_CLASSES; + if (_this.dropdown.find('.dropdown-toggle-page').length) { + selector = ".dropdown-page-one " + selector; + } + if ($(_this.el).is('input')) { + currentIndex = -1; + } else { + $(selector, _this.dropdown).first().find('a').addClass('is-focused'); + currentIndex = 0; + } } + }; + })(this) + }); + } + // Event listeners + this.dropdown.on("shown.bs.dropdown", this.opened); + this.dropdown.on("hidden.bs.dropdown", this.hidden); + $(this.el).on("update.label", this.updateLabel); + this.dropdown.on("click", ".dropdown-menu, .dropdown-menu-close", this.shouldPropagate); + this.dropdown.on('keyup', (function(_this) { + return function(e) { + // Escape key + if (e.which === 27) { + return $('.dropdown-menu-close', _this.dropdown).trigger('click'); + } + }; + })(this)); + this.dropdown.on('blur', 'a', (function(_this) { + return function(e) { + var $dropdownMenu, $relatedTarget; + if (e.relatedTarget != null) { + $relatedTarget = $(e.relatedTarget); + $dropdownMenu = $relatedTarget.closest('.dropdown-menu'); + if ($dropdownMenu.length === 0) { + return _this.dropdown.removeClass('open'); } + } + }; + })(this)); + if (this.dropdown.find(".dropdown-toggle-page").length) { + this.dropdown.find(".dropdown-toggle-page, .dropdown-menu-back").on("click", (function(_this) { + return function(e) { + e.preventDefault(); + e.stopPropagation(); + return _this.togglePage(); }; })(this)); + } + if (this.options.selectable) { + selector = ".dropdown-content a"; if (this.dropdown.find(".dropdown-toggle-page").length) { - this.dropdown.find(".dropdown-toggle-page, .dropdown-menu-back").on("click", (function(_this) { - return function(e) { - e.preventDefault(); - e.stopPropagation(); - return _this.togglePage(); - }; - })(this)); - } - if (this.options.selectable) { - selector = ".dropdown-content a"; - if (this.dropdown.find(".dropdown-toggle-page").length) { - selector = ".dropdown-page-one .dropdown-content a"; + selector = ".dropdown-page-one .dropdown-content a"; + } + this.dropdown.on("click", selector, function(e) { + var $el, selected, selectedObj, isMarking; + $el = $(this); + selected = self.rowClicked($el); + selectedObj = selected ? selected[0] : null; + isMarking = selected ? selected[1] : null; + if (self.options.clicked) { + self.options.clicked(selectedObj, $el, e, isMarking); } - this.dropdown.on("click", selector, function(e) { - var $el, selected, selectedObj, isMarking; - $el = $(this); - selected = self.rowClicked($el); - selectedObj = selected ? selected[0] : null; - isMarking = selected ? selected[1] : null; - if (self.options.clicked) { - self.options.clicked(selectedObj, $el, e, isMarking); - } - // Update label right after all modifications in dropdown has been done - if (self.options.toggleLabel) { - self.updateLabel(selectedObj, $el, self); - } + // Update label right after all modifications in dropdown has been done + if (self.options.toggleLabel) { + self.updateLabel(selectedObj, $el, self); + } - $el.trigger('blur'); - }); - } + $el.trigger('blur'); + }); } + } - // Finds an element inside wrapper element - GitLabDropdown.prototype.getElement = function(selector) { - return this.dropdown.find(selector); - }; + // Finds an element inside wrapper element + GitLabDropdown.prototype.getElement = function(selector) { + return this.dropdown.find(selector); + }; - GitLabDropdown.prototype.toggleLoading = function() { - return $('.dropdown-menu', this.dropdown).toggleClass(LOADING_CLASS); - }; + GitLabDropdown.prototype.toggleLoading = function() { + return $('.dropdown-menu', this.dropdown).toggleClass(LOADING_CLASS); + }; - GitLabDropdown.prototype.togglePage = function() { - var menu; - menu = $('.dropdown-menu', this.dropdown); - if (menu.hasClass(PAGE_TWO_CLASS)) { - if (this.remote) { - this.remote.execute(); - } - } - menu.toggleClass(PAGE_TWO_CLASS); - // Focus first visible input on active page - return this.dropdown.find('[class^="dropdown-page-"]:visible :text:visible:first').focus(); - }; - - GitLabDropdown.prototype.parseData = function(data) { - var full_html, groupData, html, name; - this.renderedData = data; - if (this.options.filterable && data.length === 0) { - // render no matching results - html = [this.noResults()]; - } else { - // Handle array groups - if (gl.utils.isObject(data)) { - html = []; - for (name in data) { - groupData = data[name]; - html.push(this.renderItem({ - header: name - // Add header for each group - }, name)); - this.renderData(groupData, name).map(function(item) { - return html.push(item); - }); - } - } else { - // Render each row - html = this.renderData(data); - } - } - // Render the full menu - full_html = this.renderMenu(html); - return this.appendMenu(full_html); - }; - - GitLabDropdown.prototype.renderData = function(data, group) { - if (group == null) { - group = false; + GitLabDropdown.prototype.togglePage = function() { + var menu; + menu = $('.dropdown-menu', this.dropdown); + if (menu.hasClass(PAGE_TWO_CLASS)) { + if (this.remote) { + this.remote.execute(); } - return data.map((function(_this) { - return function(obj, index) { - return _this.renderItem(obj, group, index); - }; - })(this)); - }; - - GitLabDropdown.prototype.shouldPropagate = function(e) { - var $target; - if (this.options.multiSelect) { - $target = $(e.target); - if ($target && !$target.hasClass('dropdown-menu-close') && - !$target.hasClass('dropdown-menu-close-icon') && - !$target.data('is-link')) { - e.stopPropagation(); - return false; - } else { - return true; + } + menu.toggleClass(PAGE_TWO_CLASS); + // Focus first visible input on active page + return this.dropdown.find('[class^="dropdown-page-"]:visible :text:visible:first').focus(); + }; + + GitLabDropdown.prototype.parseData = function(data) { + var full_html, groupData, html, name; + this.renderedData = data; + if (this.options.filterable && data.length === 0) { + // render no matching results + html = [this.noResults()]; + } else { + // Handle array groups + if (gl.utils.isObject(data)) { + html = []; + for (name in data) { + groupData = data[name]; + html.push(this.renderItem({ + header: name + // Add header for each group + }, name)); + this.renderData(groupData, name).map(function(item) { + return html.push(item); + }); } + } else { + // Render each row + html = this.renderData(data); } - }; + } + // Render the full menu + full_html = this.renderMenu(html); + return this.appendMenu(full_html); + }; - GitLabDropdown.prototype.opened = function(e) { - var contentHtml; - this.resetRows(); - this.addArrowKeyEvent(); + GitLabDropdown.prototype.renderData = function(data, group) { + if (group == null) { + group = false; + } + return data.map((function(_this) { + return function(obj, index) { + return _this.renderItem(obj, group, index); + }; + })(this)); + }; - // Makes indeterminate items effective - if (this.fullData && this.dropdown.find('.dropdown-menu-toggle').hasClass('js-filter-bulk-update')) { - this.parseData(this.fullData); - } - contentHtml = $('.dropdown-content', this.dropdown).html(); - if (this.remote && contentHtml === "") { - this.remote.execute(); + GitLabDropdown.prototype.shouldPropagate = function(e) { + var $target; + if (this.options.multiSelect) { + $target = $(e.target); + if ($target && !$target.hasClass('dropdown-menu-close') && + !$target.hasClass('dropdown-menu-close-icon') && + !$target.data('is-link')) { + e.stopPropagation(); + return false; } else { - this.focusTextInput(); + return true; } + } + }; - if (this.options.showMenuAbove) { - this.positionMenuAbove(); - } + GitLabDropdown.prototype.opened = function(e) { + var contentHtml; + this.resetRows(); + this.addArrowKeyEvent(); - if (this.options.opened) { - this.options.opened.call(this, e); - } + // Makes indeterminate items effective + if (this.fullData && this.dropdown.find('.dropdown-menu-toggle').hasClass('js-filter-bulk-update')) { + this.parseData(this.fullData); + } + contentHtml = $('.dropdown-content', this.dropdown).html(); + if (this.remote && contentHtml === "") { + this.remote.execute(); + } else { + this.focusTextInput(); + } + + if (this.options.showMenuAbove) { + this.positionMenuAbove(); + } - return this.dropdown.trigger('shown.gl.dropdown'); - }; + if (this.options.opened) { + this.options.opened.call(this, e); + } - GitLabDropdown.prototype.positionMenuAbove = function() { - var $button = $(this.el); - var $menu = this.dropdown.find('.dropdown-menu'); + return this.dropdown.trigger('shown.gl.dropdown'); + }; - $menu.css('top', ($button.height() + $menu.height()) * -1); - }; + GitLabDropdown.prototype.positionMenuAbove = function() { + var $button = $(this.el); + var $menu = this.dropdown.find('.dropdown-menu'); - GitLabDropdown.prototype.hidden = function(e) { - var $input; - this.resetRows(); - this.removeArrayKeyEvent(); - $input = this.dropdown.find(".dropdown-input-field"); - if (this.options.filterable) { - $input.blur(); - } - if (this.dropdown.find(".dropdown-toggle-page").length) { - $('.dropdown-menu', this.dropdown).removeClass(PAGE_TWO_CLASS); - } - if (this.options.hidden) { - this.options.hidden.call(this, e); - } - return this.dropdown.trigger('hidden.gl.dropdown'); - }; + $menu.css('top', ($button.height() + $menu.height()) * -1); + }; - // Render the full menu - GitLabDropdown.prototype.renderMenu = function(html) { - if (this.options.renderMenu) { - return this.options.renderMenu(html); - } else { - var ul = document.createElement('ul'); + GitLabDropdown.prototype.hidden = function(e) { + var $input; + this.resetRows(); + this.removeArrayKeyEvent(); + $input = this.dropdown.find(".dropdown-input-field"); + if (this.options.filterable) { + $input.blur(); + } + if (this.dropdown.find(".dropdown-toggle-page").length) { + $('.dropdown-menu', this.dropdown).removeClass(PAGE_TWO_CLASS); + } + if (this.options.hidden) { + this.options.hidden.call(this, e); + } + return this.dropdown.trigger('hidden.gl.dropdown'); + }; - for (var i = 0; i < html.length; i += 1) { - var el = html[i]; + // Render the full menu + GitLabDropdown.prototype.renderMenu = function(html) { + if (this.options.renderMenu) { + return this.options.renderMenu(html); + } else { + var ul = document.createElement('ul'); - if (el instanceof jQuery) { - el = el.get(0); - } + for (var i = 0; i < html.length; i += 1) { + var el = html[i]; - if (typeof el === 'string') { - ul.innerHTML += el; - } else { - ul.appendChild(el); - } + if (el instanceof jQuery) { + el = el.get(0); } - return ul; + if (typeof el === 'string') { + ul.innerHTML += el; + } else { + ul.appendChild(el); + } } - }; - // Append the menu into the dropdown - GitLabDropdown.prototype.appendMenu = function(html) { - return this.clearMenu().append(html); - }; + return ul; + } + }; + + // Append the menu into the dropdown + GitLabDropdown.prototype.appendMenu = function(html) { + return this.clearMenu().append(html); + }; - GitLabDropdown.prototype.clearMenu = function() { - var selector; - selector = '.dropdown-content'; - if (this.dropdown.find(".dropdown-toggle-page").length) { - selector = ".dropdown-page-one .dropdown-content"; - } + GitLabDropdown.prototype.clearMenu = function() { + var selector; + selector = '.dropdown-content'; + if (this.dropdown.find(".dropdown-toggle-page").length) { + selector = ".dropdown-page-one .dropdown-content"; + } - return $(selector, this.dropdown).empty(); - }; + return $(selector, this.dropdown).empty(); + }; - GitLabDropdown.prototype.renderItem = function(data, group, index) { - var field, fieldName, html, selected, text, url, value; - if (group == null) { - group = false; + GitLabDropdown.prototype.renderItem = function(data, group, index) { + var field, fieldName, html, selected, text, url, value; + if (group == null) { + group = false; + } + if (index == null) { + // Render the row + index = false; + } + html = document.createElement('li'); + if (data === 'divider' || data === 'separator') { + html.className = data; + return html; + } + // Header + if (data.header != null) { + html.className = 'dropdown-header'; + html.innerHTML = data.header; + return html; + } + if (this.options.renderRow) { + // Call the render function + html = this.options.renderRow.call(this.options, data, this); + } else { + if (!selected) { + value = this.options.id ? this.options.id(data) : data.id; + fieldName = this.options.fieldName; + + if (value) { value = value.toString().replace(/'/g, '\\\''); } + + field = this.dropdown.parent().find("input[name='" + fieldName + "'][value='" + value + "']"); + if (field.length) { + selected = true; + } } - if (index == null) { - // Render the row - index = false; + // Set URL + if (this.options.url != null) { + url = this.options.url(data); + } else { + url = data.url != null ? data.url : '#'; } - html = document.createElement('li'); - if (data === 'divider' || data === 'separator') { - html.className = data; - return html; + // Set Text + if (this.options.text != null) { + text = this.options.text(data); + } else { + text = data.text != null ? data.text : ''; } - // Header - if (data.header != null) { - html.className = 'dropdown-header'; - html.innerHTML = data.header; - return html; + if (this.highlight) { + text = this.highlightTextMatches(text, this.filterInput.val()); } - if (this.options.renderRow) { - // Call the render function - html = this.options.renderRow.call(this.options, data, this); - } else { - if (!selected) { - value = this.options.id ? this.options.id(data) : data.id; - fieldName = this.options.fieldName; - - if (value) { value = value.toString().replace(/'/g, '\\\''); } - - field = this.dropdown.parent().find("input[name='" + fieldName + "'][value='" + value + "']"); - if (field.length) { - selected = true; - } - } - // Set URL - if (this.options.url != null) { - url = this.options.url(data); - } else { - url = data.url != null ? data.url : '#'; - } - // Set Text - if (this.options.text != null) { - text = this.options.text(data); - } else { - text = data.text != null ? data.text : ''; - } - if (this.highlight) { - text = this.highlightTextMatches(text, this.filterInput.val()); - } - // Create the list item & the link - var link = document.createElement('a'); - - link.href = url; - link.innerHTML = text; + // Create the list item & the link + var link = document.createElement('a'); - if (selected) { - link.className = 'is-active'; - } - - if (group) { - link.dataset.group = group; - link.dataset.index = index; - } + link.href = url; + link.innerHTML = text; - html.appendChild(link); + if (selected) { + link.className = 'is-active'; } - return html; - }; - - GitLabDropdown.prototype.highlightTextMatches = function(text, term) { - var occurrences; - occurrences = fuzzaldrinPlus.match(text, term); - return text.split('').map(function(character, i) { - if (indexOf.call(occurrences, i) !== -1) { - return "" + character + ""; - } else { - return character; - } - }).join(''); - }; - - GitLabDropdown.prototype.noResults = function() { - var html; - return html = "
    "; - }; - - GitLabDropdown.prototype.rowClicked = function(el) { - var field, fieldName, groupName, isInput, selectedIndex, selectedObject, value, isMarking; - - fieldName = this.options.fieldName; - isInput = $(this.el).is('input'); - if (this.renderedData) { - groupName = el.data('group'); - if (groupName) { - selectedIndex = el.data('index'); - selectedObject = this.renderedData[groupName][selectedIndex]; - } else { - selectedIndex = el.closest('li').index(); - selectedObject = this.renderedData[selectedIndex]; - } + + if (group) { + link.dataset.group = group; + link.dataset.index = index; } - if (this.options.vue) { - if (el.hasClass(ACTIVE_CLASS)) { - el.removeClass(ACTIVE_CLASS); - } else { - el.addClass(ACTIVE_CLASS); - } + html.appendChild(link); + } + return html; + }; - return [selectedObject]; + GitLabDropdown.prototype.highlightTextMatches = function(text, term) { + var occurrences; + occurrences = fuzzaldrinPlus.match(text, term); + return text.split('').map(function(character, i) { + if (indexOf.call(occurrences, i) !== -1) { + return "" + character + ""; + } else { + return character; } + }).join(''); + }; - field = []; - value = this.options.id - ? this.options.id(selectedObject, el) - : selectedObject.id; - if (isInput) { - field = $(this.el); - } else if (value) { - field = this.dropdown.parent().find("input[name='" + fieldName + "'][value='" + value.toString().replace(/'/g, '\\\'') + "']"); - } + GitLabDropdown.prototype.noResults = function() { + var html; + return html = ""; + }; + + GitLabDropdown.prototype.rowClicked = function(el) { + var field, fieldName, groupName, isInput, selectedIndex, selectedObject, value, isMarking; - if (this.options.isSelectable && !this.options.isSelectable(selectedObject, el)) { - return; + fieldName = this.options.fieldName; + isInput = $(this.el).is('input'); + if (this.renderedData) { + groupName = el.data('group'); + if (groupName) { + selectedIndex = el.data('index'); + selectedObject = this.renderedData[groupName][selectedIndex]; + } else { + selectedIndex = el.closest('li').index(); + selectedObject = this.renderedData[selectedIndex]; } + } + if (this.options.vue) { if (el.hasClass(ACTIVE_CLASS)) { - isMarking = false; el.removeClass(ACTIVE_CLASS); - if (field && field.length) { - this.clearField(field, isInput); - } - } else if (el.hasClass(INDETERMINATE_CLASS)) { - isMarking = true; - el.addClass(ACTIVE_CLASS); - el.removeClass(INDETERMINATE_CLASS); - if (field && field.length && value == null) { - this.clearField(field, isInput); - } - if ((!field || !field.length) && fieldName) { - this.addInput(fieldName, value, selectedObject); - } } else { - isMarking = true; - if (!this.options.multiSelect || el.hasClass('dropdown-clear-active')) { - this.dropdown.find("." + ACTIVE_CLASS).removeClass(ACTIVE_CLASS); - if (!isInput) { - this.dropdown.parent().find("input[name='" + fieldName + "']").remove(); - } - } - if (field && field.length && value == null) { - this.clearField(field, isInput); - } - // Toggle active class for the tick mark el.addClass(ACTIVE_CLASS); - if (value != null) { - if ((!field || !field.length) && fieldName) { - this.addInput(fieldName, value, selectedObject); - } else if (field && field.length) { - field.val(value).trigger('change'); - } - } } - return [selectedObject, isMarking]; - }; + return [selectedObject]; + } - GitLabDropdown.prototype.focusTextInput = function() { - if (this.options.filterable) { this.filterInput.focus(); } - }; + field = []; + value = this.options.id + ? this.options.id(selectedObject, el) + : selectedObject.id; + if (isInput) { + field = $(this.el); + } else if (value) { + field = this.dropdown.parent().find("input[name='" + fieldName + "'][value='" + value.toString().replace(/'/g, '\\\'') + "']"); + } - GitLabDropdown.prototype.addInput = function(fieldName, value, selectedObject) { - var $input; - // Create hidden input for form - $input = $('').attr('type', 'hidden').attr('name', fieldName).val(value); - if (this.options.inputId != null) { - $input.attr('id', this.options.inputId); - } - return this.dropdown.before($input); - }; - - GitLabDropdown.prototype.selectRowAtIndex = function(index) { - var $el, selector; - // If we pass an option index - if (typeof index !== "undefined") { - selector = SELECTABLE_CLASSES + ":eq(" + index + ") a"; - } else { - selector = ".dropdown-content .is-focused"; + if (this.options.isSelectable && !this.options.isSelectable(selectedObject, el)) { + return; + } + + if (el.hasClass(ACTIVE_CLASS)) { + isMarking = false; + el.removeClass(ACTIVE_CLASS); + if (field && field.length) { + this.clearField(field, isInput); + } + } else if (el.hasClass(INDETERMINATE_CLASS)) { + isMarking = true; + el.addClass(ACTIVE_CLASS); + el.removeClass(INDETERMINATE_CLASS); + if (field && field.length && value == null) { + this.clearField(field, isInput); + } + if ((!field || !field.length) && fieldName) { + this.addInput(fieldName, value, selectedObject); + } + } else { + isMarking = true; + if (!this.options.multiSelect || el.hasClass('dropdown-clear-active')) { + this.dropdown.find("." + ACTIVE_CLASS).removeClass(ACTIVE_CLASS); + if (!isInput) { + this.dropdown.parent().find("input[name='" + fieldName + "']").remove(); + } } - if (this.dropdown.find(".dropdown-toggle-page").length) { - selector = ".dropdown-page-one " + selector; + if (field && field.length && value == null) { + this.clearField(field, isInput); } - // simulate a click on the first link - $el = $(selector, this.dropdown); - if ($el.length) { - var href = $el.attr('href'); - if (href && href !== '#') { - gl.utils.visitUrl(href); - } else { - $el.first().trigger('click'); + // Toggle active class for the tick mark + el.addClass(ACTIVE_CLASS); + if (value != null) { + if ((!field || !field.length) && fieldName) { + this.addInput(fieldName, value, selectedObject); + } else if (field && field.length) { + field.val(value).trigger('change'); } } - }; + } - GitLabDropdown.prototype.addArrowKeyEvent = function() { - var $input, ARROW_KEY_CODES, selector; - ARROW_KEY_CODES = [38, 40]; - $input = this.dropdown.find(".dropdown-input-field"); - selector = SELECTABLE_CLASSES; - if (this.dropdown.find(".dropdown-toggle-page").length) { - selector = ".dropdown-page-one " + selector; + return [selectedObject, isMarking]; + }; + + GitLabDropdown.prototype.focusTextInput = function() { + if (this.options.filterable) { this.filterInput.focus(); } + }; + + GitLabDropdown.prototype.addInput = function(fieldName, value, selectedObject) { + var $input; + // Create hidden input for form + $input = $('').attr('type', 'hidden').attr('name', fieldName).val(value); + if (this.options.inputId != null) { + $input.attr('id', this.options.inputId); + } + return this.dropdown.before($input); + }; + + GitLabDropdown.prototype.selectRowAtIndex = function(index) { + var $el, selector; + // If we pass an option index + if (typeof index !== "undefined") { + selector = SELECTABLE_CLASSES + ":eq(" + index + ") a"; + } else { + selector = ".dropdown-content .is-focused"; + } + if (this.dropdown.find(".dropdown-toggle-page").length) { + selector = ".dropdown-page-one " + selector; + } + // simulate a click on the first link + $el = $(selector, this.dropdown); + if ($el.length) { + var href = $el.attr('href'); + if (href && href !== '#') { + gl.utils.visitUrl(href); + } else { + $el.first().trigger('click'); } - return $('body').on('keydown', (function(_this) { - return function(e) { - var $listItems, PREV_INDEX, currentKeyCode; - currentKeyCode = e.which; - if (ARROW_KEY_CODES.indexOf(currentKeyCode) !== -1) { - e.preventDefault(); - e.stopImmediatePropagation(); - PREV_INDEX = currentIndex; - $listItems = $(selector, _this.dropdown); - // if @options.filterable - // $input.blur() - if (currentKeyCode === 40) { - // Move down - if (currentIndex < ($listItems.length - 1)) { - currentIndex += 1; - } - } else if (currentKeyCode === 38) { - // Move up - if (currentIndex > 0) { - currentIndex -= 1; - } + } + }; + + GitLabDropdown.prototype.addArrowKeyEvent = function() { + var $input, ARROW_KEY_CODES, selector; + ARROW_KEY_CODES = [38, 40]; + $input = this.dropdown.find(".dropdown-input-field"); + selector = SELECTABLE_CLASSES; + if (this.dropdown.find(".dropdown-toggle-page").length) { + selector = ".dropdown-page-one " + selector; + } + return $('body').on('keydown', (function(_this) { + return function(e) { + var $listItems, PREV_INDEX, currentKeyCode; + currentKeyCode = e.which; + if (ARROW_KEY_CODES.indexOf(currentKeyCode) !== -1) { + e.preventDefault(); + e.stopImmediatePropagation(); + PREV_INDEX = currentIndex; + $listItems = $(selector, _this.dropdown); + // if @options.filterable + // $input.blur() + if (currentKeyCode === 40) { + // Move down + if (currentIndex < ($listItems.length - 1)) { + currentIndex += 1; } - if (currentIndex !== PREV_INDEX) { - _this.highlightRowAtIndex($listItems, currentIndex); + } else if (currentKeyCode === 38) { + // Move up + if (currentIndex > 0) { + currentIndex -= 1; } - return false; } - if (currentKeyCode === 13 && currentIndex !== -1) { - e.preventDefault(); - _this.selectRowAtIndex(); + if (currentIndex !== PREV_INDEX) { + _this.highlightRowAtIndex($listItems, currentIndex); } - }; - })(this)); - }; - - GitLabDropdown.prototype.removeArrayKeyEvent = function() { - return $('body').off('keydown'); - }; - - GitLabDropdown.prototype.resetRows = function resetRows() { - currentIndex = -1; - $('.is-focused', this.dropdown).removeClass('is-focused'); - }; - - GitLabDropdown.prototype.highlightRowAtIndex = function($listItems, index) { - var $dropdownContent, $listItem, dropdownContentBottom, dropdownContentHeight, dropdownContentTop, dropdownScrollTop, listItemBottom, listItemHeight, listItemTop; - // Remove the class for the previously focused row - $('.is-focused', this.dropdown).removeClass('is-focused'); - // Update the class for the row at the specific index - $listItem = $listItems.eq(index); - $listItem.find('a:first-child').addClass("is-focused"); - // Dropdown content scroll area - $dropdownContent = $listItem.closest('.dropdown-content'); - dropdownScrollTop = $dropdownContent.scrollTop(); - dropdownContentHeight = $dropdownContent.outerHeight(); - dropdownContentTop = $dropdownContent.prop('offsetTop'); - dropdownContentBottom = dropdownContentTop + dropdownContentHeight; - // Get the offset bottom of the list item - listItemHeight = $listItem.outerHeight(); - listItemTop = $listItem.prop('offsetTop'); - listItemBottom = listItemTop + listItemHeight; - if (!index) { - // Scroll the dropdown content to the top - $dropdownContent.scrollTop(0); - } else if (index === ($listItems.length - 1)) { - // Scroll the dropdown content to the bottom - $dropdownContent.scrollTop($dropdownContent.prop('scrollHeight')); - } else if (listItemBottom > (dropdownContentBottom + dropdownScrollTop)) { - // Scroll the dropdown content down - $dropdownContent.scrollTop(listItemBottom - dropdownContentBottom + CURSOR_SELECT_SCROLL_PADDING); - } else if (listItemTop < (dropdownContentTop + dropdownScrollTop)) { - // Scroll the dropdown content up - return $dropdownContent.scrollTop(listItemTop - dropdownContentTop - CURSOR_SELECT_SCROLL_PADDING); - } - }; + return false; + } + if (currentKeyCode === 13 && currentIndex !== -1) { + e.preventDefault(); + _this.selectRowAtIndex(); + } + }; + })(this)); + }; - GitLabDropdown.prototype.updateLabel = function(selected, el, instance) { - if (selected == null) { - selected = null; - } - if (el == null) { - el = null; - } - if (instance == null) { - instance = null; - } - return $(this.el).find(".dropdown-toggle-text").text(this.options.toggleLabel(selected, el, instance)); - }; + GitLabDropdown.prototype.removeArrayKeyEvent = function() { + return $('body').off('keydown'); + }; - GitLabDropdown.prototype.clearField = function(field, isInput) { - return isInput ? field.val('') : field.remove(); - }; + GitLabDropdown.prototype.resetRows = function resetRows() { + currentIndex = -1; + $('.is-focused', this.dropdown).removeClass('is-focused'); + }; - return GitLabDropdown; - })(); + GitLabDropdown.prototype.highlightRowAtIndex = function($listItems, index) { + var $dropdownContent, $listItem, dropdownContentBottom, dropdownContentHeight, dropdownContentTop, dropdownScrollTop, listItemBottom, listItemHeight, listItemTop; + // Remove the class for the previously focused row + $('.is-focused', this.dropdown).removeClass('is-focused'); + // Update the class for the row at the specific index + $listItem = $listItems.eq(index); + $listItem.find('a:first-child').addClass("is-focused"); + // Dropdown content scroll area + $dropdownContent = $listItem.closest('.dropdown-content'); + dropdownScrollTop = $dropdownContent.scrollTop(); + dropdownContentHeight = $dropdownContent.outerHeight(); + dropdownContentTop = $dropdownContent.prop('offsetTop'); + dropdownContentBottom = dropdownContentTop + dropdownContentHeight; + // Get the offset bottom of the list item + listItemHeight = $listItem.outerHeight(); + listItemTop = $listItem.prop('offsetTop'); + listItemBottom = listItemTop + listItemHeight; + if (!index) { + // Scroll the dropdown content to the top + $dropdownContent.scrollTop(0); + } else if (index === ($listItems.length - 1)) { + // Scroll the dropdown content to the bottom + $dropdownContent.scrollTop($dropdownContent.prop('scrollHeight')); + } else if (listItemBottom > (dropdownContentBottom + dropdownScrollTop)) { + // Scroll the dropdown content down + $dropdownContent.scrollTop(listItemBottom - dropdownContentBottom + CURSOR_SELECT_SCROLL_PADDING); + } else if (listItemTop < (dropdownContentTop + dropdownScrollTop)) { + // Scroll the dropdown content up + return $dropdownContent.scrollTop(listItemTop - dropdownContentTop - CURSOR_SELECT_SCROLL_PADDING); + } + }; - $.fn.glDropdown = function(opts) { - return this.each(function() { - if (!$.data(this, 'glDropdown')) { - return $.data(this, 'glDropdown', new GitLabDropdown(this, opts)); - } - }); + GitLabDropdown.prototype.updateLabel = function(selected, el, instance) { + if (selected == null) { + selected = null; + } + if (el == null) { + el = null; + } + if (instance == null) { + instance = null; + } + return $(this.el).find(".dropdown-toggle-text").text(this.options.toggleLabel(selected, el, instance)); + }; + + GitLabDropdown.prototype.clearField = function(field, isInput) { + return isInput ? field.val('') : field.remove(); }; -}).call(window); + + return GitLabDropdown; +})(); + +$.fn.glDropdown = function(opts) { + return this.each(function() { + if (!$.data(this, 'glDropdown')) { + return $.data(this, 'glDropdown', new GitLabDropdown(this, opts)); + } + }); +}; diff --git a/app/assets/javascripts/gl_field_error.js b/app/assets/javascripts/gl_field_error.js index f7cbecc0385..76de249ac3b 100644 --- a/app/assets/javascripts/gl_field_error.js +++ b/app/assets/javascripts/gl_field_error.js @@ -1,164 +1,162 @@ -/* eslint-disable no-param-reassign */ -((global) => { - /* - * This class overrides the browser's validation error bubbles, displaying custom - * error messages for invalid fields instead. To begin validating any form, add the - * class `gl-show-field-errors` to the form element, and ensure error messages are - * declared in each inputs' `title` attribute. If no title is declared for an invalid - * field the user attempts to submit, "This field is required." will be shown by default. - * - * Opt not to validate certain fields by adding the class `gl-field-error-ignore` to the input. - * - * Set a custom error anchor for error message to be injected after with the - * class `gl-field-error-anchor` - * - * Examples: - * - * Basic: - * - *
    - * - *
    - * - * Ignore specific inputs (e.g. UsernameValidator): - * - *
    - *
    - * - *
    - *
    - * - * Custom Error Anchor (allows error message to be injected after specified element): - * - *
    - *
    - * - * // Error message typically injected here - *
    - * // Error message now injected here - *
    - * - * */ - - /* - * Regex Patterns in use: - * - * Only alphanumeric: : "[a-zA-Z0-9]+" - * No special characters : "[a-zA-Z0-9-_]+", - * - * */ - - const errorMessageClass = 'gl-field-error'; - const inputErrorClass = 'gl-field-error-outline'; - const errorAnchorSelector = '.gl-field-error-anchor'; - const ignoreInputSelector = '.gl-field-error-ignore'; - - class GlFieldError { - constructor({ input, formErrors }) { - this.inputElement = $(input); - this.inputDomElement = this.inputElement.get(0); - this.form = formErrors; - this.errorMessage = this.inputElement.attr('title') || 'This field is required.'; - this.fieldErrorElement = $(`

    ${this.errorMessage}

    `); - - this.state = { - valid: false, - empty: true, - }; - - this.initFieldValidation(); - } +/** + * This class overrides the browser's validation error bubbles, displaying custom + * error messages for invalid fields instead. To begin validating any form, add the + * class `gl-show-field-errors` to the form element, and ensure error messages are + * declared in each inputs' `title` attribute. If no title is declared for an invalid + * field the user attempts to submit, "This field is required." will be shown by default. + * + * Opt not to validate certain fields by adding the class `gl-field-error-ignore` to the input. + * + * Set a custom error anchor for error message to be injected after with the + * class `gl-field-error-anchor` + * + * Examples: + * + * Basic: + * + *
    + * + *
    + * + * Ignore specific inputs (e.g. UsernameValidator): + * + *
    + *
    + * + *
    + *
    + * + * Custom Error Anchor (allows error message to be injected after specified element): + * + *
    + *
    + * + * // Error message typically injected here + *
    + * // Error message now injected here + *
    + * + */ + +/** + * Regex Patterns in use: + * + * Only alphanumeric: : "[a-zA-Z0-9]+" + * No special characters : "[a-zA-Z0-9-_]+", + * + */ + +const errorMessageClass = 'gl-field-error'; +const inputErrorClass = 'gl-field-error-outline'; +const errorAnchorSelector = '.gl-field-error-anchor'; +const ignoreInputSelector = '.gl-field-error-ignore'; + +class GlFieldError { + constructor({ input, formErrors }) { + this.inputElement = $(input); + this.inputDomElement = this.inputElement.get(0); + this.form = formErrors; + this.errorMessage = this.inputElement.attr('title') || 'This field is required.'; + this.fieldErrorElement = $(`

    ${this.errorMessage}

    `); + + this.state = { + valid: false, + empty: true, + }; + + this.initFieldValidation(); + } - initFieldValidation() { - const customErrorAnchor = this.inputElement.parents(errorAnchorSelector); - const errorAnchor = customErrorAnchor.length ? customErrorAnchor : this.inputElement; + initFieldValidation() { + const customErrorAnchor = this.inputElement.parents(errorAnchorSelector); + const errorAnchor = customErrorAnchor.length ? customErrorAnchor : this.inputElement; - // hidden when injected into DOM - errorAnchor.after(this.fieldErrorElement); - this.inputElement.off('invalid').on('invalid', this.handleInvalidSubmit.bind(this)); - this.scopedSiblings = this.safelySelectSiblings(); - } + // hidden when injected into DOM + errorAnchor.after(this.fieldErrorElement); + this.inputElement.off('invalid').on('invalid', this.handleInvalidSubmit.bind(this)); + this.scopedSiblings = this.safelySelectSiblings(); + } - safelySelectSiblings() { - // Apply `ignoreSelector` in markup to siblings whose visibility should not be toggled - const unignoredSiblings = this.inputElement.siblings(`p:not(${ignoreInputSelector})`); - const parentContainer = this.inputElement.parent('.form-group'); + safelySelectSiblings() { + // Apply `ignoreSelector` in markup to siblings whose visibility should not be toggled + const unignoredSiblings = this.inputElement.siblings(`p:not(${ignoreInputSelector})`); + const parentContainer = this.inputElement.parent('.form-group'); - // Only select siblings when they're scoped within a form-group with one input - const safelyScoped = parentContainer.length && parentContainer.find('input').length === 1; + // Only select siblings when they're scoped within a form-group with one input + const safelyScoped = parentContainer.length && parentContainer.find('input').length === 1; - return safelyScoped ? unignoredSiblings : this.fieldErrorElement; - } + return safelyScoped ? unignoredSiblings : this.fieldErrorElement; + } - renderValidity() { - this.renderClear(); + renderValidity() { + this.renderClear(); - if (this.state.valid) { - this.renderValid(); - } else if (this.state.empty) { - this.renderEmpty(); - } else if (!this.state.valid) { - this.renderInvalid(); - } + if (this.state.valid) { + this.renderValid(); + } else if (this.state.empty) { + this.renderEmpty(); + } else if (!this.state.valid) { + this.renderInvalid(); } + } - handleInvalidSubmit(event) { - event.preventDefault(); - const currentValue = this.accessCurrentValue(); - this.state.valid = false; - this.state.empty = currentValue === ''; - - this.renderValidity(); - this.form.focusOnFirstInvalid.apply(this.form); - // For UX, wait til after first invalid submission to check each keyup - this.inputElement.off('keyup.fieldValidator') - .on('keyup.fieldValidator', this.updateValidity.bind(this)); - } + handleInvalidSubmit(event) { + event.preventDefault(); + const currentValue = this.accessCurrentValue(); + this.state.valid = false; + this.state.empty = currentValue === ''; + + this.renderValidity(); + this.form.focusOnFirstInvalid.apply(this.form); + // For UX, wait til after first invalid submission to check each keyup + this.inputElement.off('keyup.fieldValidator') + .on('keyup.fieldValidator', this.updateValidity.bind(this)); + } - /* Get or set current input value */ - accessCurrentValue(newVal) { - return newVal ? this.inputElement.val(newVal) : this.inputElement.val(); - } + /* Get or set current input value */ + accessCurrentValue(newVal) { + return newVal ? this.inputElement.val(newVal) : this.inputElement.val(); + } - getInputValidity() { - return this.inputDomElement.validity.valid; - } + getInputValidity() { + return this.inputDomElement.validity.valid; + } - updateValidity() { - const inputVal = this.accessCurrentValue(); - this.state.empty = !inputVal.length; - this.state.valid = this.getInputValidity(); - this.renderValidity(); - } + updateValidity() { + const inputVal = this.accessCurrentValue(); + this.state.empty = !inputVal.length; + this.state.valid = this.getInputValidity(); + this.renderValidity(); + } - renderValid() { - return this.renderClear(); - } + renderValid() { + return this.renderClear(); + } - renderEmpty() { - return this.renderInvalid(); - } + renderEmpty() { + return this.renderInvalid(); + } - renderInvalid() { - this.inputElement.addClass(inputErrorClass); - this.scopedSiblings.hide(); - return this.fieldErrorElement.show(); - } + renderInvalid() { + this.inputElement.addClass(inputErrorClass); + this.scopedSiblings.hide(); + return this.fieldErrorElement.show(); + } - renderClear() { - const inputVal = this.accessCurrentValue(); - if (!inputVal.split(' ').length) { - const trimmedInput = inputVal.trim(); - this.accessCurrentValue(trimmedInput); - } - this.inputElement.removeClass(inputErrorClass); - this.scopedSiblings.hide(); - this.fieldErrorElement.hide(); + renderClear() { + const inputVal = this.accessCurrentValue(); + if (!inputVal.split(' ').length) { + const trimmedInput = inputVal.trim(); + this.accessCurrentValue(trimmedInput); } + this.inputElement.removeClass(inputErrorClass); + this.scopedSiblings.hide(); + this.fieldErrorElement.hide(); } +} - global.GlFieldError = GlFieldError; -})(window.gl || (window.gl = {})); +window.gl = window.gl || {}; +window.gl.GlFieldError = GlFieldError; diff --git a/app/assets/javascripts/gl_field_errors.js b/app/assets/javascripts/gl_field_errors.js index e9add115429..636258ec555 100644 --- a/app/assets/javascripts/gl_field_errors.js +++ b/app/assets/javascripts/gl_field_errors.js @@ -2,47 +2,46 @@ require('./gl_field_error'); -((global) => { - const customValidationFlag = 'gl-field-error-ignore'; - - class GlFieldErrors { - constructor(form) { - this.form = $(form); - this.state = { - inputs: [], - valid: false - }; - this.initValidators(); - } +const customValidationFlag = 'gl-field-error-ignore'; + +class GlFieldErrors { + constructor(form) { + this.form = $(form); + this.state = { + inputs: [], + valid: false + }; + this.initValidators(); + } - initValidators () { - // register selectors here as needed - const validateSelectors = [':text', ':password', '[type=email]'] - .map((selector) => `input${selector}`).join(','); + initValidators () { + // register selectors here as needed + const validateSelectors = [':text', ':password', '[type=email]'] + .map((selector) => `input${selector}`).join(','); - this.state.inputs = this.form.find(validateSelectors).toArray() - .filter((input) => !input.classList.contains(customValidationFlag)) - .map((input) => new global.GlFieldError({ input, formErrors: this })); + this.state.inputs = this.form.find(validateSelectors).toArray() + .filter((input) => !input.classList.contains(customValidationFlag)) + .map((input) => new window.gl.GlFieldError({ input, formErrors: this })); - this.form.on('submit', this.catchInvalidFormSubmit); - } + this.form.on('submit', this.catchInvalidFormSubmit); + } - /* Neccessary to prevent intercept and override invalid form submit - * because Safari & iOS quietly allow form submission when form is invalid - * and prevents disabling of invalid submit button by application.js */ + /* Neccessary to prevent intercept and override invalid form submit + * because Safari & iOS quietly allow form submission when form is invalid + * and prevents disabling of invalid submit button by application.js */ - catchInvalidFormSubmit (event) { - if (!event.currentTarget.checkValidity()) { - event.preventDefault(); - event.stopPropagation(); - } + catchInvalidFormSubmit (event) { + if (!event.currentTarget.checkValidity()) { + event.preventDefault(); + event.stopPropagation(); } + } - focusOnFirstInvalid () { - const firstInvalid = this.state.inputs.filter((input) => !input.inputDomElement.validity.valid)[0]; - firstInvalid.inputElement.focus(); - } + focusOnFirstInvalid () { + const firstInvalid = this.state.inputs.filter((input) => !input.inputDomElement.validity.valid)[0]; + firstInvalid.inputElement.focus(); } +} - global.GlFieldErrors = GlFieldErrors; -})(window.gl || (window.gl = {})); +window.gl = window.gl || {}; +window.gl.GlFieldErrors = GlFieldErrors; diff --git a/app/assets/javascripts/gl_form.js b/app/assets/javascripts/gl_form.js index 0b446ff364a..e7c98e16581 100644 --- a/app/assets/javascripts/gl_form.js +++ b/app/assets/javascripts/gl_form.js @@ -3,90 +3,88 @@ /* global DropzoneInput */ /* global autosize */ -(() => { - const global = window.gl || (window.gl = {}); +window.gl = window.gl || {}; - function GLForm(form) { - this.form = form; - this.textarea = this.form.find('textarea.js-gfm-input'); - // Before we start, we should clean up any previous data for this form - this.destroy(); - // Setup the form - this.setupForm(); - this.form.data('gl-form', this); - } +function GLForm(form) { + this.form = form; + this.textarea = this.form.find('textarea.js-gfm-input'); + // Before we start, we should clean up any previous data for this form + this.destroy(); + // Setup the form + this.setupForm(); + this.form.data('gl-form', this); +} - GLForm.prototype.destroy = function() { - // Clean form listeners - this.clearEventListeners(); - return this.form.data('gl-form', null); - }; +GLForm.prototype.destroy = function() { + // Clean form listeners + this.clearEventListeners(); + return this.form.data('gl-form', null); +}; - GLForm.prototype.setupForm = function() { - var isNewForm; - isNewForm = this.form.is(':not(.gfm-form)'); - this.form.removeClass('js-new-note-form'); - if (isNewForm) { - this.form.find('.div-dropzone').remove(); - this.form.addClass('gfm-form'); - // remove notify commit author checkbox for non-commit notes - gl.utils.disableButtonIfEmptyField(this.form.find('.js-note-text'), this.form.find('.js-comment-button')); - gl.GfmAutoComplete.setup(this.form.find('.js-gfm-input')); - new DropzoneInput(this.form); - autosize(this.textarea); - // form and textarea event listeners - this.addEventListeners(); - } - gl.text.init(this.form); - // hide discard button - this.form.find('.js-note-discard').hide(); - this.form.show(); - if (this.isAutosizeable) this.setupAutosize(); - }; +GLForm.prototype.setupForm = function() { + var isNewForm; + isNewForm = this.form.is(':not(.gfm-form)'); + this.form.removeClass('js-new-note-form'); + if (isNewForm) { + this.form.find('.div-dropzone').remove(); + this.form.addClass('gfm-form'); + // remove notify commit author checkbox for non-commit notes + gl.utils.disableButtonIfEmptyField(this.form.find('.js-note-text'), this.form.find('.js-comment-button')); + gl.GfmAutoComplete.setup(this.form.find('.js-gfm-input')); + new DropzoneInput(this.form); + autosize(this.textarea); + // form and textarea event listeners + this.addEventListeners(); + } + gl.text.init(this.form); + // hide discard button + this.form.find('.js-note-discard').hide(); + this.form.show(); + if (this.isAutosizeable) this.setupAutosize(); +}; - GLForm.prototype.setupAutosize = function () { - this.textarea.off('autosize:resized') - .on('autosize:resized', this.setHeightData.bind(this)); +GLForm.prototype.setupAutosize = function () { + this.textarea.off('autosize:resized') + .on('autosize:resized', this.setHeightData.bind(this)); - this.textarea.off('mouseup.autosize') - .on('mouseup.autosize', this.destroyAutosize.bind(this)); + this.textarea.off('mouseup.autosize') + .on('mouseup.autosize', this.destroyAutosize.bind(this)); - setTimeout(() => { - autosize(this.textarea); - this.textarea.css('resize', 'vertical'); - }, 0); - }; + setTimeout(() => { + autosize(this.textarea); + this.textarea.css('resize', 'vertical'); + }, 0); +}; - GLForm.prototype.setHeightData = function () { - this.textarea.data('height', this.textarea.outerHeight()); - }; +GLForm.prototype.setHeightData = function () { + this.textarea.data('height', this.textarea.outerHeight()); +}; - GLForm.prototype.destroyAutosize = function () { - const outerHeight = this.textarea.outerHeight(); +GLForm.prototype.destroyAutosize = function () { + const outerHeight = this.textarea.outerHeight(); - if (this.textarea.data('height') === outerHeight) return; + if (this.textarea.data('height') === outerHeight) return; - autosize.destroy(this.textarea); + autosize.destroy(this.textarea); - this.textarea.data('height', outerHeight); - this.textarea.outerHeight(outerHeight); - this.textarea.css('max-height', window.outerHeight); - }; + this.textarea.data('height', outerHeight); + this.textarea.outerHeight(outerHeight); + this.textarea.css('max-height', window.outerHeight); +}; - GLForm.prototype.clearEventListeners = function() { - this.textarea.off('focus'); - this.textarea.off('blur'); - return gl.text.removeListeners(this.form); - }; +GLForm.prototype.clearEventListeners = function() { + this.textarea.off('focus'); + this.textarea.off('blur'); + return gl.text.removeListeners(this.form); +}; - GLForm.prototype.addEventListeners = function() { - this.textarea.on('focus', function() { - return $(this).closest('.md-area').addClass('is-focused'); - }); - return this.textarea.on('blur', function() { - return $(this).closest('.md-area').removeClass('is-focused'); - }); - }; +GLForm.prototype.addEventListeners = function() { + this.textarea.on('focus', function() { + return $(this).closest('.md-area').addClass('is-focused'); + }); + return this.textarea.on('blur', function() { + return $(this).closest('.md-area').removeClass('is-focused'); + }); +}; - global.GLForm = GLForm; -})(); +window.gl.GLForm = GLForm; diff --git a/app/assets/javascripts/group_avatar.js b/app/assets/javascripts/group_avatar.js index c5cb273c5b2..f03b47b1c1d 100644 --- a/app/assets/javascripts/group_avatar.js +++ b/app/assets/javascripts/group_avatar.js @@ -1,20 +1,19 @@ /* eslint-disable func-names, space-before-function-paren, wrap-iife, quotes, no-var, one-var, one-var-declaration-per-line, no-useless-escape, max-len */ -(function() { - this.GroupAvatar = (function() { - function GroupAvatar() { - $('.js-choose-group-avatar-button').on("click", function() { - var form; - form = $(this).closest("form"); - return form.find(".js-group-avatar-input").click(); - }); - $('.js-group-avatar-input').on("change", function() { - var filename, form; - form = $(this).closest("form"); - filename = $(this).val().replace(/^.*[\\\/]/, ''); - return form.find(".js-avatar-filename").text(filename); - }); - } - return GroupAvatar; - })(); -}).call(window); +window.GroupAvatar = (function() { + function GroupAvatar() { + $('.js-choose-group-avatar-button').on("click", function() { + var form; + form = $(this).closest("form"); + return form.find(".js-group-avatar-input").click(); + }); + $('.js-group-avatar-input').on("change", function() { + var filename, form; + form = $(this).closest("form"); + filename = $(this).val().replace(/^.*[\\\/]/, ''); + return form.find(".js-avatar-filename").text(filename); + }); + } + + return GroupAvatar; +})(); diff --git a/app/assets/javascripts/group_label_subscription.js b/app/assets/javascripts/group_label_subscription.js index 15e695e81cf..7dc9ce898e8 100644 --- a/app/assets/javascripts/group_label_subscription.js +++ b/app/assets/javascripts/group_label_subscription.js @@ -1,53 +1,52 @@ /* eslint-disable func-names, object-shorthand, comma-dangle, wrap-iife, space-before-function-paren, no-param-reassign, max-len */ -(function(global) { - class GroupLabelSubscription { - constructor(container) { - const $container = $(container); - this.$dropdown = $container.find('.dropdown'); - this.$subscribeButtons = $container.find('.js-subscribe-button'); - this.$unsubscribeButtons = $container.find('.js-unsubscribe-button'); - - this.$subscribeButtons.on('click', this.subscribe.bind(this)); - this.$unsubscribeButtons.on('click', this.unsubscribe.bind(this)); - } - - unsubscribe(event) { - event.preventDefault(); - - const url = this.$unsubscribeButtons.attr('data-url'); - - $.ajax({ - type: 'POST', - url: url - }).done(() => { - this.toggleSubscriptionButtons(); - this.$unsubscribeButtons.removeAttr('data-url'); - }); - } - - subscribe(event) { - event.preventDefault(); - - const $btn = $(event.currentTarget); - const url = $btn.attr('data-url'); - - this.$unsubscribeButtons.attr('data-url', url); - - $.ajax({ - type: 'POST', - url: url - }).done(() => { - this.toggleSubscriptionButtons(); - }); - } - - toggleSubscriptionButtons() { - this.$dropdown.toggleClass('hidden'); - this.$subscribeButtons.toggleClass('hidden'); - this.$unsubscribeButtons.toggleClass('hidden'); - } +class GroupLabelSubscription { + constructor(container) { + const $container = $(container); + this.$dropdown = $container.find('.dropdown'); + this.$subscribeButtons = $container.find('.js-subscribe-button'); + this.$unsubscribeButtons = $container.find('.js-unsubscribe-button'); + + this.$subscribeButtons.on('click', this.subscribe.bind(this)); + this.$unsubscribeButtons.on('click', this.unsubscribe.bind(this)); } - global.GroupLabelSubscription = GroupLabelSubscription; -})(window.gl || (window.gl = {})); + unsubscribe(event) { + event.preventDefault(); + + const url = this.$unsubscribeButtons.attr('data-url'); + + $.ajax({ + type: 'POST', + url: url + }).done(() => { + this.toggleSubscriptionButtons(); + this.$unsubscribeButtons.removeAttr('data-url'); + }); + } + + subscribe(event) { + event.preventDefault(); + + const $btn = $(event.currentTarget); + const url = $btn.attr('data-url'); + + this.$unsubscribeButtons.attr('data-url', url); + + $.ajax({ + type: 'POST', + url: url + }).done(() => { + this.toggleSubscriptionButtons(); + }); + } + + toggleSubscriptionButtons() { + this.$dropdown.toggleClass('hidden'); + this.$subscribeButtons.toggleClass('hidden'); + this.$unsubscribeButtons.toggleClass('hidden'); + } +} + +window.gl = window.gl || {}; +window.gl.GroupLabelSubscription = GroupLabelSubscription; diff --git a/app/assets/javascripts/groups_select.js b/app/assets/javascripts/groups_select.js index 6b937e7fa0f..e5dfa30edab 100644 --- a/app/assets/javascripts/groups_select.js +++ b/app/assets/javascripts/groups_select.js @@ -1,71 +1,69 @@ /* eslint-disable func-names, space-before-function-paren, no-var, wrap-iife, one-var, camelcase, one-var-declaration-per-line, quotes, object-shorthand, prefer-arrow-callback, comma-dangle, consistent-return, yoda, prefer-rest-params, prefer-spread, no-unused-vars, prefer-template, max-len */ /* global Api */ -(function() { - var slice = [].slice; +var slice = [].slice; - this.GroupsSelect = (function() { - function GroupsSelect() { - $('.ajax-groups-select').each((function(_this) { - return function(i, select) { - var all_available, skip_groups; - all_available = $(select).data('all-available'); - skip_groups = $(select).data('skip-groups') || []; - return $(select).select2({ - placeholder: "Search for a group", - multiple: $(select).hasClass('multiselect'), - minimumInputLength: 0, - query: function(query) { - var options = { all_available: all_available, skip_groups: skip_groups }; - return Api.groups(query.term, options, function(groups) { - var data; - data = { - results: groups - }; - return query.callback(data); - }); - }, - initSelection: function(element, callback) { - var id; - id = $(element).val(); - if (id !== "") { - return Api.group(id, callback); - } - }, - formatResult: function() { - var args; - args = 1 <= arguments.length ? slice.call(arguments, 0) : []; - return _this.formatResult.apply(_this, args); - }, - formatSelection: function() { - var args; - args = 1 <= arguments.length ? slice.call(arguments, 0) : []; - return _this.formatSelection.apply(_this, args); - }, - dropdownCssClass: "ajax-groups-dropdown", - // we do not want to escape markup since we are displaying html in results - escapeMarkup: function(m) { - return m; +window.GroupsSelect = (function() { + function GroupsSelect() { + $('.ajax-groups-select').each((function(_this) { + return function(i, select) { + var all_available, skip_groups; + all_available = $(select).data('all-available'); + skip_groups = $(select).data('skip-groups') || []; + return $(select).select2({ + placeholder: "Search for a group", + multiple: $(select).hasClass('multiselect'), + minimumInputLength: 0, + query: function(query) { + var options = { all_available: all_available, skip_groups: skip_groups }; + return Api.groups(query.term, options, function(groups) { + var data; + data = { + results: groups + }; + return query.callback(data); + }); + }, + initSelection: function(element, callback) { + var id; + id = $(element).val(); + if (id !== "") { + return Api.group(id, callback); } - }); - }; - })(this)); - } + }, + formatResult: function() { + var args; + args = 1 <= arguments.length ? slice.call(arguments, 0) : []; + return _this.formatResult.apply(_this, args); + }, + formatSelection: function() { + var args; + args = 1 <= arguments.length ? slice.call(arguments, 0) : []; + return _this.formatSelection.apply(_this, args); + }, + dropdownCssClass: "ajax-groups-dropdown", + // we do not want to escape markup since we are displaying html in results + escapeMarkup: function(m) { + return m; + } + }); + }; + })(this)); + } - GroupsSelect.prototype.formatResult = function(group) { - var avatar; - if (group.avatar_url) { - avatar = group.avatar_url; - } else { - avatar = gon.default_avatar_url; - } - return "
    " + group.full_name + "
    " + group.full_path + "
    "; - }; + GroupsSelect.prototype.formatResult = function(group) { + var avatar; + if (group.avatar_url) { + avatar = group.avatar_url; + } else { + avatar = gon.default_avatar_url; + } + return "
    " + group.full_name + "
    " + group.full_path + "
    "; + }; - GroupsSelect.prototype.formatSelection = function(group) { - return group.full_name; - }; + GroupsSelect.prototype.formatSelection = function(group) { + return group.full_name; + }; - return GroupsSelect; - })(); -}).call(window); + return GroupsSelect; +})(); diff --git a/app/assets/javascripts/header.js b/app/assets/javascripts/header.js index a853c3aeb1f..34f44dad7a5 100644 --- a/app/assets/javascripts/header.js +++ b/app/assets/javascripts/header.js @@ -1,8 +1,7 @@ -/* eslint-disable wrap-iife, func-names, space-before-function-paren, prefer-arrow-callback, no-var, max-len */ -(function() { - $(document).on('todo:toggle', function(e, count) { - var $todoPendingCount = $('.todos-pending-count'); - $todoPendingCount.text(gl.text.highCountTrim(count)); - $todoPendingCount.toggleClass('hidden', count === 0); - }); -})(); +/* eslint-disable func-names, space-before-function-paren, prefer-arrow-callback, no-var */ + +$(document).on('todo:toggle', function(e, count) { + var $todoPendingCount = $('.todos-pending-count'); + $todoPendingCount.text(gl.text.highCountTrim(count)); + $todoPendingCount.toggleClass('hidden', count === 0); +}); diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 604ed91627a..c078af23ef9 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -206,189 +206,187 @@ import './visibility_select'; import './wikis'; import './zen_mode'; -(function () { - document.addEventListener('beforeunload', function () { - // Unbind scroll events - $(document).off('scroll'); - // Close any open tooltips - $('.has-tooltip, [data-toggle="tooltip"]').tooltip('destroy'); - }); - - window.addEventListener('hashchange', gl.utils.handleLocationHash); - window.addEventListener('load', function onLoad() { - window.removeEventListener('load', onLoad, false); - gl.utils.handleLocationHash(); - }, false); +document.addEventListener('beforeunload', function () { + // Unbind scroll events + $(document).off('scroll'); + // Close any open tooltips + $('.has-tooltip, [data-toggle="tooltip"]').tooltip('destroy'); +}); - $(function () { - var $body = $('body'); - var $document = $(document); - var $window = $(window); - var $sidebarGutterToggle = $('.js-sidebar-toggle'); - var $flash = $('.flash-container'); - var bootstrapBreakpoint = bp.getBreakpointSize(); - var fitSidebarForSize; +window.addEventListener('hashchange', gl.utils.handleLocationHash); +window.addEventListener('load', function onLoad() { + window.removeEventListener('load', onLoad, false); + gl.utils.handleLocationHash(); +}, false); - // Set the default path for all cookies to GitLab's root directory - Cookies.defaults.path = gon.relative_url_root || '/'; +$(function () { + var $body = $('body'); + var $document = $(document); + var $window = $(window); + var $sidebarGutterToggle = $('.js-sidebar-toggle'); + var $flash = $('.flash-container'); + var bootstrapBreakpoint = bp.getBreakpointSize(); + var fitSidebarForSize; - // `hashchange` is not triggered when link target is already in window.location - $body.on('click', 'a[href^="#"]', function() { - var href = this.getAttribute('href'); - if (href.substr(1) === gl.utils.getLocationHash()) { - setTimeout(gl.utils.handleLocationHash, 1); - } - }); + // Set the default path for all cookies to GitLab's root directory + Cookies.defaults.path = gon.relative_url_root || '/'; - // prevent default action for disabled buttons - $('.btn').click(function(e) { - if ($(this).hasClass('disabled')) { - e.preventDefault(); - e.stopImmediatePropagation(); - return false; - } - }); + // `hashchange` is not triggered when link target is already in window.location + $body.on('click', 'a[href^="#"]', function() { + var href = this.getAttribute('href'); + if (href.substr(1) === gl.utils.getLocationHash()) { + setTimeout(gl.utils.handleLocationHash, 1); + } + }); - $('.js-select-on-focus').on('focusin', function () { - return $(this).select().one('mouseup', function (e) { - return e.preventDefault(); - }); - // Click a .js-select-on-focus field, select the contents - // Prevent a mouseup event from deselecting the input - }); - $('.remove-row').bind('ajax:success', function () { - $(this).tooltip('destroy') - .closest('li') - .fadeOut(); - }); - $('.js-remove-tr').bind('ajax:before', function () { - return $(this).hide(); - }); - $('.js-remove-tr').bind('ajax:success', function () { - return $(this).closest('tr').fadeOut(); - }); - $('select.select2').select2({ - width: 'resolve', - // Initialize select2 selects - dropdownAutoWidth: true - }); - $('.js-select2').bind('select2-close', function () { - return setTimeout((function () { - $('.select2-container-active').removeClass('select2-container-active'); - return $(':focus').blur(); - }), 1); - // Close select2 on escape - }); - // Initialize tooltips - $.fn.tooltip.Constructor.DEFAULTS.trigger = 'hover'; - $body.tooltip({ - selector: '.has-tooltip, [data-toggle="tooltip"]', - placement: function (tip, el) { - return $(el).data('placement') || 'bottom'; - } - }); - $('.trigger-submit').on('change', function () { - return $(this).parents('form').submit(); - // Form submitter - }); - gl.utils.localTimeAgo($('abbr.timeago, .js-timeago'), true); - // Flash - if ($flash.length > 0) { - $flash.click(function () { - return $(this).fadeOut(); - }); - $flash.show(); + // prevent default action for disabled buttons + $('.btn').click(function(e) { + if ($(this).hasClass('disabled')) { + e.preventDefault(); + e.stopImmediatePropagation(); + return false; } - // Disable form buttons while a form is submitting - $body.on('ajax:complete, ajax:beforeSend, submit', 'form', function (e) { - var buttons; - buttons = $('[type="submit"]', this); - switch (e.type) { - case 'ajax:beforeSend': - case 'submit': - return buttons.disable(); - default: - return buttons.enable(); - } - }); - $(document).ajaxError(function (e, xhrObj) { - var ref = xhrObj.status; - if (xhrObj.status === 401) { - return new Flash('You need to be logged in.', 'alert'); - } else if (ref === 404 || ref === 500) { - return new Flash('Something went wrong on our end.', 'alert'); - } - }); - $('.account-box').hover(function () { - // Show/Hide the profile menu when hovering the account box - return $(this).toggleClass('hover'); - }); - $document.on('click', '.diff-content .js-show-suppressed-diff', function () { - var $container; - $container = $(this).parent(); - $container.next('table').show(); - return $container.remove(); - // Commit show suppressed diff - }); - $('.navbar-toggle').on('click', function () { - $('.header-content .title').toggle(); - $('.header-content .header-logo').toggle(); - $('.header-content .navbar-collapse').toggle(); - return $('.navbar-toggle').toggleClass('active'); - }); - // Show/hide comments on diff - $body.on('click', '.js-toggle-diff-comments', function (e) { - var $this = $(this); - var notesHolders = $this.closest('.diff-file').find('.notes_holder'); - $this.toggleClass('active'); - if ($this.hasClass('active')) { - notesHolders.show().find('.hide, .content').show(); - } else { - notesHolders.hide().find('.content').hide(); - } - $(document).trigger('toggle.comments'); + }); + + $('.js-select-on-focus').on('focusin', function () { + return $(this).select().one('mouseup', function (e) { return e.preventDefault(); }); - $document.off('click', '.js-confirm-danger'); - $document.on('click', '.js-confirm-danger', function (e) { - var btn = $(e.target); - var form = btn.closest('form'); - var text = btn.data('confirm-danger-message'); - e.preventDefault(); - return new ConfirmDangerModal(form, text); - }); - $('input[type="search"]').each(function () { - var $this = $(this); - $this.attr('value', $this.val()); - }); - $document.off('keyup', 'input[type="search"]').on('keyup', 'input[type="search"]', function () { - var $this; - $this = $(this); - return $this.attr('value', $this.val()); - }); - $document.off('breakpoint:change').on('breakpoint:change', function (e, breakpoint) { - var $gutterIcon; - if (breakpoint === 'sm' || breakpoint === 'xs') { - $gutterIcon = $sidebarGutterToggle.find('i'); - if ($gutterIcon.hasClass('fa-angle-double-right')) { - return $sidebarGutterToggle.trigger('click'); - } - } + // Click a .js-select-on-focus field, select the contents + // Prevent a mouseup event from deselecting the input + }); + $('.remove-row').bind('ajax:success', function () { + $(this).tooltip('destroy') + .closest('li') + .fadeOut(); + }); + $('.js-remove-tr').bind('ajax:before', function () { + return $(this).hide(); + }); + $('.js-remove-tr').bind('ajax:success', function () { + return $(this).closest('tr').fadeOut(); + }); + $('select.select2').select2({ + width: 'resolve', + // Initialize select2 selects + dropdownAutoWidth: true + }); + $('.js-select2').bind('select2-close', function () { + return setTimeout((function () { + $('.select2-container-active').removeClass('select2-container-active'); + return $(':focus').blur(); + }), 1); + // Close select2 on escape + }); + // Initialize tooltips + $.fn.tooltip.Constructor.DEFAULTS.trigger = 'hover'; + $body.tooltip({ + selector: '.has-tooltip, [data-toggle="tooltip"]', + placement: function (tip, el) { + return $(el).data('placement') || 'bottom'; + } + }); + $('.trigger-submit').on('change', function () { + return $(this).parents('form').submit(); + // Form submitter + }); + gl.utils.localTimeAgo($('abbr.timeago, .js-timeago'), true); + // Flash + if ($flash.length > 0) { + $flash.click(function () { + return $(this).fadeOut(); }); - fitSidebarForSize = function () { - var oldBootstrapBreakpoint; - oldBootstrapBreakpoint = bootstrapBreakpoint; - bootstrapBreakpoint = bp.getBreakpointSize(); - if (bootstrapBreakpoint !== oldBootstrapBreakpoint) { - return $document.trigger('breakpoint:change', [bootstrapBreakpoint]); + $flash.show(); + } + // Disable form buttons while a form is submitting + $body.on('ajax:complete, ajax:beforeSend, submit', 'form', function (e) { + var buttons; + buttons = $('[type="submit"]', this); + switch (e.type) { + case 'ajax:beforeSend': + case 'submit': + return buttons.disable(); + default: + return buttons.enable(); + } + }); + $(document).ajaxError(function (e, xhrObj) { + var ref = xhrObj.status; + if (xhrObj.status === 401) { + return new Flash('You need to be logged in.', 'alert'); + } else if (ref === 404 || ref === 500) { + return new Flash('Something went wrong on our end.', 'alert'); + } + }); + $('.account-box').hover(function () { + // Show/Hide the profile menu when hovering the account box + return $(this).toggleClass('hover'); + }); + $document.on('click', '.diff-content .js-show-suppressed-diff', function () { + var $container; + $container = $(this).parent(); + $container.next('table').show(); + return $container.remove(); + // Commit show suppressed diff + }); + $('.navbar-toggle').on('click', function () { + $('.header-content .title').toggle(); + $('.header-content .header-logo').toggle(); + $('.header-content .navbar-collapse').toggle(); + return $('.navbar-toggle').toggleClass('active'); + }); + // Show/hide comments on diff + $body.on('click', '.js-toggle-diff-comments', function (e) { + var $this = $(this); + var notesHolders = $this.closest('.diff-file').find('.notes_holder'); + $this.toggleClass('active'); + if ($this.hasClass('active')) { + notesHolders.show().find('.hide, .content').show(); + } else { + notesHolders.hide().find('.content').hide(); + } + $(document).trigger('toggle.comments'); + return e.preventDefault(); + }); + $document.off('click', '.js-confirm-danger'); + $document.on('click', '.js-confirm-danger', function (e) { + var btn = $(e.target); + var form = btn.closest('form'); + var text = btn.data('confirm-danger-message'); + e.preventDefault(); + return new ConfirmDangerModal(form, text); + }); + $('input[type="search"]').each(function () { + var $this = $(this); + $this.attr('value', $this.val()); + }); + $document.off('keyup', 'input[type="search"]').on('keyup', 'input[type="search"]', function () { + var $this; + $this = $(this); + return $this.attr('value', $this.val()); + }); + $document.off('breakpoint:change').on('breakpoint:change', function (e, breakpoint) { + var $gutterIcon; + if (breakpoint === 'sm' || breakpoint === 'xs') { + $gutterIcon = $sidebarGutterToggle.find('i'); + if ($gutterIcon.hasClass('fa-angle-double-right')) { + return $sidebarGutterToggle.trigger('click'); } - }; - $window.off('resize.app').on('resize.app', function () { - return fitSidebarForSize(); - }); - gl.awardsHandler = new AwardsHandler(); - new Aside(); - - gl.utils.initTimeagoTimeout(); + } }); -}).call(window); + fitSidebarForSize = function () { + var oldBootstrapBreakpoint; + oldBootstrapBreakpoint = bootstrapBreakpoint; + bootstrapBreakpoint = bp.getBreakpointSize(); + if (bootstrapBreakpoint !== oldBootstrapBreakpoint) { + return $document.trigger('breakpoint:change', [bootstrapBreakpoint]); + } + }; + $window.off('resize.app').on('resize.app', function () { + return fitSidebarForSize(); + }); + gl.awardsHandler = new AwardsHandler(); + new Aside(); + + gl.utils.initTimeagoTimeout(); +}); -- cgit v1.2.1 From c8f986fe115d19c3f6ec014e20c7399918914d10 Mon Sep 17 00:00:00 2001 From: Mike Greiling Date: Mon, 13 Mar 2017 14:08:49 -0500 Subject: fix broken variable reference --- app/assets/javascripts/abuse_reports.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/javascripts/abuse_reports.js b/app/assets/javascripts/abuse_reports.js index 2934565caec..346de4ad11e 100644 --- a/app/assets/javascripts/abuse_reports.js +++ b/app/assets/javascripts/abuse_reports.js @@ -15,7 +15,7 @@ class AbuseReports { if (reportMessage.length > MAX_MESSAGE_LENGTH) { $messageCellElement.data('original-message', reportMessage); $messageCellElement.data('message-truncated', 'true'); - $messageCellElement.text(global.text.truncate(reportMessage, MAX_MESSAGE_LENGTH)); + $messageCellElement.text(window.gl.text.truncate(reportMessage, MAX_MESSAGE_LENGTH)); } } -- cgit v1.2.1 From 985af1a6707af531a242051e46a54c16dc31b9bc Mon Sep 17 00:00:00 2001 From: mhasbini Date: Mon, 13 Mar 2017 22:09:43 +0200 Subject: take nonewline context into account in diff parser --- app/views/projects/diffs/_line.html.haml | 2 +- app/views/projects/diffs/_parallel_view.html.haml | 4 +- lib/gitlab/diff/line.rb | 6 +-- lib/gitlab/diff/parser.rb | 6 ++- spec/lib/gitlab/diff/parser_spec.rb | 48 +++++++++++++++++++++++ 5 files changed, 59 insertions(+), 7 deletions(-) diff --git a/app/views/projects/diffs/_line.html.haml b/app/views/projects/diffs/_line.html.haml index 62135d3ae32..c09c7b87e24 100644 --- a/app/views/projects/diffs/_line.html.haml +++ b/app/views/projects/diffs/_line.html.haml @@ -9,7 +9,7 @@ - case type - when 'match' = diff_match_line line.old_pos, line.new_pos, text: line.text - - when 'nonewline' + - when 'old-nonewline', 'new-nonewline' %td.old_line.diff-line-num %td.new_line.diff-line-num %td.line_content.match= line.text diff --git a/app/views/projects/diffs/_parallel_view.html.haml b/app/views/projects/diffs/_parallel_view.html.haml index e7758c8bdfa..b7346f27ddb 100644 --- a/app/views/projects/diffs/_parallel_view.html.haml +++ b/app/views/projects/diffs/_parallel_view.html.haml @@ -12,7 +12,7 @@ - case left.type - when 'match' = diff_match_line left.old_pos, nil, text: left.text, view: :parallel - - when 'nonewline' + - when 'old-nonewline', 'new-nonewline' %td.old_line.diff-line-num %td.line_content.match= left.text - else @@ -31,7 +31,7 @@ - case right.type - when 'match' = diff_match_line nil, right.new_pos, text: left.text, view: :parallel - - when 'nonewline' + - when 'old-nonewline', 'new-nonewline' %td.new_line.diff-line-num %td.line_content.match= right.text - else diff --git a/lib/gitlab/diff/line.rb b/lib/gitlab/diff/line.rb index 80a146b4a5a..114656958e3 100644 --- a/lib/gitlab/diff/line.rb +++ b/lib/gitlab/diff/line.rb @@ -38,11 +38,11 @@ module Gitlab end def added? - type == 'new' + type == 'new' || type == 'new-nonewline' end def removed? - type == 'old' + type == 'old' || type == 'old-nonewline' end def rich_text @@ -52,7 +52,7 @@ module Gitlab end def meta? - type == 'match' || type == 'nonewline' + type == 'match' end def as_json(opts = nil) diff --git a/lib/gitlab/diff/parser.rb b/lib/gitlab/diff/parser.rb index 8f844224a7a..742f989c50b 100644 --- a/lib/gitlab/diff/parser.rb +++ b/lib/gitlab/diff/parser.rb @@ -11,6 +11,7 @@ module Gitlab line_old = 1 line_new = 1 type = nil + context = nil # By returning an Enumerator we make it possible to search for a single line (with #find) # without having to instantiate all the others that come after it. @@ -31,7 +32,8 @@ module Gitlab line_obj_index += 1 next elsif line[0] == '\\' - type = 'nonewline' + type = "#{context}-nonewline" + yielder << Gitlab::Diff::Line.new(full_line, type, line_obj_index, line_old, line_new) line_obj_index += 1 else @@ -43,8 +45,10 @@ module Gitlab case line[0] when "+" line_new += 1 + context = :new when "-" line_old += 1 + context = :old when "\\" # rubocop:disable Lint/EmptyWhen # No increment else diff --git a/spec/lib/gitlab/diff/parser_spec.rb b/spec/lib/gitlab/diff/parser_spec.rb index b983d73f8be..e76128ecd87 100644 --- a/spec/lib/gitlab/diff/parser_spec.rb +++ b/spec/lib/gitlab/diff/parser_spec.rb @@ -91,6 +91,54 @@ eos end end + describe '\ No newline at end of file' do + it "parses nonewline in one file correctly" do + first_nonewline_diff = <<~END + --- a/test + +++ b/test + @@ -1,2 +1,2 @@ + +ipsum + lorem + -ipsum + \\ No newline at end of file + END + lines = parser.parse(first_nonewline_diff.lines).to_a + + expect(lines[0].type).to eq('new') + expect(lines[0].text).to eq('+ipsum') + expect(lines[2].type).to eq('old') + expect(lines[3].type).to eq('old-nonewline') + expect(lines[1].old_pos).to eq(1) + expect(lines[1].new_pos).to eq(2) + end + + it "parses nonewline in two files correctly" do + both_nonewline_diff = <<~END + --- a/test + +++ b/test + @@ -1,2 +1,2 @@ + -lorem + -ipsum + \\ No newline at end of file + +ipsum + +lorem + \\ No newline at end of file + END + lines = parser.parse(both_nonewline_diff.lines).to_a + + expect(lines[0].type).to eq('old') + expect(lines[1].type).to eq('old') + expect(lines[2].type).to eq('old-nonewline') + expect(lines[5].type).to eq('new-nonewline') + expect(lines[3].text).to eq('+ipsum') + expect(lines[3].old_pos).to eq(3) + expect(lines[3].new_pos).to eq(1) + expect(lines[4].text).to eq('+lorem') + expect(lines[4].old_pos).to eq(3) + expect(lines[4].new_pos).to eq(2) + end + end + context 'when lines is empty' do it { expect(parser.parse([])).to eq([]) } it { expect(parser.parse(nil)).to eq([]) } -- cgit v1.2.1 From 37ce638ccd476144fe9235a4d091be4135a3e00a Mon Sep 17 00:00:00 2001 From: blackst0ne Date: Tue, 14 Mar 2017 07:24:30 +1100 Subject: Use dig --- app/views/projects/new.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml index 560e3439fc2..d129da943f8 100644 --- a/app/views/projects/new.html.haml +++ b/app/views/projects/new.html.haml @@ -1,6 +1,6 @@ - page_title 'New Project' - header_title "Projects", dashboard_projects_path -- visibility_level = params.try(:[], :project).try(:[], :visibility_level) || default_project_visibility +- visibility_level = params.dig(:project, :visibility_level) || default_project_visibility .project-edit-container .project-edit-errors -- cgit v1.2.1 From 11dfad3e3a3209532184d828c1f70e6b774ade71 Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Fri, 20 Jan 2017 15:13:27 -0800 Subject: Add performance/scalability concerns to CONTRIBUTING.md [ci skip] --- CONTRIBUTING.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1fd29fef4f0..9a6e3feec4c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -399,6 +399,12 @@ There are a few rules to get your merge request accepted: 1. Contains functionality we think other users will benefit from too 1. Doesn't add configuration options or settings options since they complicate making and testing future changes +1. Changes do not adversely degrade performance. + - Avoid repeated polling of endpoints that require a significant amount of overhead + - Check for N+1 queries via the SQL log or [`QueryRecorder`](https://docs.gitlab.com/ce/development/merge_request_performance_guidelines.html) + - Avoid repeated access of filesystem +1. If you need polling to support real-time features, consider using this [described long + polling approach](https://gitlab.com/gitlab-org/gitlab-ce/issues/26926). 1. Changes after submitting the merge request should be in separate commits (no squashing). If necessary, you will be asked to squash when the review is over, before merging. @@ -434,6 +440,7 @@ the feature you contribute through all of these steps. 1. Description explaining the relevancy (see following item) 1. Working and clean code that is commented where needed 1. Unit and integration tests that pass on the CI server +1. Performance/scalability implications have been considered, addressed, and tested 1. [Documented][doc-styleguide] in the /doc directory 1. Changelog entry added 1. Reviewed and any concerns are addressed -- cgit v1.2.1 From 29e0cb4b91b3800ef4974c54b8473e6e4fb28e16 Mon Sep 17 00:00:00 2001 From: Mike Greiling Date: Mon, 13 Mar 2017 21:48:32 +0000 Subject: Organize our polyfills and standardize on core-js --- app/assets/javascripts/behaviors/quick_submit.js | 2 +- app/assets/javascripts/behaviors/requires_input.js | 2 +- app/assets/javascripts/commons/bootstrap.js | 8 ++++- app/assets/javascripts/commons/index.js | 1 + app/assets/javascripts/commons/polyfills.js | 10 ++++++ .../javascripts/commons/polyfills/custom_event.js | 9 +++++ .../javascripts/commons/polyfills/element.js | 20 +++++++++++ app/assets/javascripts/droplab/droplab_ajax.js | 3 ++ .../javascripts/droplab/droplab_ajax_filter.js | 3 ++ app/assets/javascripts/extensions/array.js | 28 ++++----------- app/assets/javascripts/extensions/custom_event.js | 12 ------- app/assets/javascripts/extensions/element.js | 20 ----------- app/assets/javascripts/extensions/jquery.js | 16 --------- app/assets/javascripts/extensions/object.js | 26 -------------- app/assets/javascripts/extensions/string.js | 2 -- .../filtered_search_dropdown_manager.js | 4 +++ .../filtered_search/filtered_search_manager.js | 5 +-- app/assets/javascripts/main.js | 8 ----- .../javascripts/monitoring/prometheus_graph.js | 6 ++-- changelogs/unreleased/use-corejs-polyfills.yml | 4 +++ package.json | 4 +-- spec/javascripts/awards_handler_spec.js | 3 -- .../blob/create_branch_dropdown_spec.js | 2 -- .../blob/target_branch_dropdown_spec.js | 2 -- spec/javascripts/boards/board_new_issue_spec.js | 1 - spec/javascripts/boards/boards_store_spec.js | 1 - spec/javascripts/bootstrap_jquery_spec.js | 42 ++++++++++++++++++++++ spec/javascripts/extensions/array_spec.js | 23 ------------ spec/javascripts/extensions/element_spec.js | 38 -------------------- spec/javascripts/extensions/jquery_spec.js | 42 ---------------------- spec/javascripts/extensions/object_spec.js | 25 ------------- .../filtered_search_manager_spec.js | 5 ++- spec/javascripts/gl_emoji_spec.js | 3 -- .../monitoring/prometheus_graph_spec.js | 3 -- spec/javascripts/polyfills/element_spec.js | 36 +++++++++++++++++++ spec/javascripts/right_sidebar_spec.js | 4 +-- spec/javascripts/shortcuts_issuable_spec.js | 8 ++--- yarn.lock | 12 ++----- 38 files changed, 165 insertions(+), 278 deletions(-) create mode 100644 app/assets/javascripts/commons/polyfills.js create mode 100644 app/assets/javascripts/commons/polyfills/custom_event.js create mode 100644 app/assets/javascripts/commons/polyfills/element.js delete mode 100644 app/assets/javascripts/extensions/custom_event.js delete mode 100644 app/assets/javascripts/extensions/element.js delete mode 100644 app/assets/javascripts/extensions/jquery.js delete mode 100644 app/assets/javascripts/extensions/object.js delete mode 100644 app/assets/javascripts/extensions/string.js create mode 100644 changelogs/unreleased/use-corejs-polyfills.yml create mode 100644 spec/javascripts/bootstrap_jquery_spec.js delete mode 100644 spec/javascripts/extensions/element_spec.js delete mode 100644 spec/javascripts/extensions/jquery_spec.js delete mode 100644 spec/javascripts/extensions/object_spec.js create mode 100644 spec/javascripts/polyfills/element_spec.js diff --git a/app/assets/javascripts/behaviors/quick_submit.js b/app/assets/javascripts/behaviors/quick_submit.js index a7e68ae5cb9..626f3503c91 100644 --- a/app/assets/javascripts/behaviors/quick_submit.js +++ b/app/assets/javascripts/behaviors/quick_submit.js @@ -6,7 +6,7 @@ // "Meta+Enter" (Mac) or "Ctrl+Enter" (Linux/Windows) key combination, the form // is submitted. // -require('../extensions/jquery'); +import '../commons/bootstrap'; // // ### Example Markup diff --git a/app/assets/javascripts/behaviors/requires_input.js b/app/assets/javascripts/behaviors/requires_input.js index 6b21695d082..eb7143f5b1a 100644 --- a/app/assets/javascripts/behaviors/requires_input.js +++ b/app/assets/javascripts/behaviors/requires_input.js @@ -4,7 +4,7 @@ // When called on a form with input fields with the `required` attribute, the // form's submit button will be disabled until all required fields have values. // -require('../extensions/jquery'); +import '../commons/bootstrap'; // // ### Example Markup diff --git a/app/assets/javascripts/commons/bootstrap.js b/app/assets/javascripts/commons/bootstrap.js index db0cbfd87c3..36bfe457be9 100644 --- a/app/assets/javascripts/commons/bootstrap.js +++ b/app/assets/javascripts/commons/bootstrap.js @@ -1,4 +1,4 @@ -import 'jquery'; +import $ from 'jquery'; // bootstrap jQuery plugins import 'bootstrap-sass/assets/javascripts/bootstrap/affix'; @@ -8,3 +8,9 @@ import 'bootstrap-sass/assets/javascripts/bootstrap/modal'; import 'bootstrap-sass/assets/javascripts/bootstrap/tab'; import 'bootstrap-sass/assets/javascripts/bootstrap/transition'; import 'bootstrap-sass/assets/javascripts/bootstrap/tooltip'; + +// custom jQuery functions +$.fn.extend({ + disable() { return $(this).attr('disabled', 'disabled').addClass('disabled'); }, + enable() { return $(this).removeAttr('disabled').removeClass('disabled'); }, +}); diff --git a/app/assets/javascripts/commons/index.js b/app/assets/javascripts/commons/index.js index 72ede1d621a..7063f59d446 100644 --- a/app/assets/javascripts/commons/index.js +++ b/app/assets/javascripts/commons/index.js @@ -1,2 +1,3 @@ +import './polyfills'; import './jquery'; import './bootstrap'; diff --git a/app/assets/javascripts/commons/polyfills.js b/app/assets/javascripts/commons/polyfills.js new file mode 100644 index 00000000000..fbd0db64ca7 --- /dev/null +++ b/app/assets/javascripts/commons/polyfills.js @@ -0,0 +1,10 @@ +// ECMAScript polyfills +import 'core-js/fn/array/find'; +import 'core-js/fn/object/assign'; +import 'core-js/fn/promise'; +import 'core-js/fn/string/code-point-at'; +import 'core-js/fn/string/from-code-point'; + +// Browser polyfills +import './polyfills/custom_event'; +import './polyfills/element'; diff --git a/app/assets/javascripts/commons/polyfills/custom_event.js b/app/assets/javascripts/commons/polyfills/custom_event.js new file mode 100644 index 00000000000..aea61b82d03 --- /dev/null +++ b/app/assets/javascripts/commons/polyfills/custom_event.js @@ -0,0 +1,9 @@ +if (typeof window.CustomEvent !== 'function') { + window.CustomEvent = function CustomEvent(event, params) { + const evt = document.createEvent('CustomEvent'); + const evtParams = params || { bubbles: false, cancelable: false, detail: undefined }; + evt.initCustomEvent(event, evtParams.bubbles, evtParams.cancelable, evtParams.detail); + return evt; + }; + window.CustomEvent.prototype = Event; +} diff --git a/app/assets/javascripts/commons/polyfills/element.js b/app/assets/javascripts/commons/polyfills/element.js new file mode 100644 index 00000000000..9a1f73bf2ac --- /dev/null +++ b/app/assets/javascripts/commons/polyfills/element.js @@ -0,0 +1,20 @@ +Element.prototype.closest = Element.prototype.closest || + function closest(selector, selectedElement = this) { + if (!selectedElement) return null; + return selectedElement.matches(selector) ? + selectedElement : + Element.prototype.closest(selector, selectedElement.parentElement); + }; + +Element.prototype.matches = Element.prototype.matches || + Element.prototype.matchesSelector || + Element.prototype.mozMatchesSelector || + Element.prototype.msMatchesSelector || + Element.prototype.oMatchesSelector || + Element.prototype.webkitMatchesSelector || + function matches(selector) { + const elms = (this.document || this.ownerDocument).querySelectorAll(selector); + let i = elms.length - 1; + while (i >= 0 && elms.item(i) !== this) { i -= 1; } + return i > -1; + }; diff --git a/app/assets/javascripts/droplab/droplab_ajax.js b/app/assets/javascripts/droplab/droplab_ajax.js index f61be741b4a..020f8b4ac65 100644 --- a/app/assets/javascripts/droplab/droplab_ajax.js +++ b/app/assets/javascripts/droplab/droplab_ajax.js @@ -74,6 +74,9 @@ require('../window')(function(w){ this._loadUrlData(config.endpoint) .then(function(d) { self._loadData(d, config, self); + }, function(xhrError) { + // TODO: properly handle errors due to XHR cancellation + return; }).catch(function(e) { throw new droplabAjaxException(e.message || e); }); diff --git a/app/assets/javascripts/droplab/droplab_ajax_filter.js b/app/assets/javascripts/droplab/droplab_ajax_filter.js index b63d73066cb..05eba7aef56 100644 --- a/app/assets/javascripts/droplab/droplab_ajax_filter.js +++ b/app/assets/javascripts/droplab/droplab_ajax_filter.js @@ -82,6 +82,9 @@ require('../window')(function(w){ this._loadUrlData(url) .then(function(data) { self._loadData(data, config, self); + }, function(xhrError) { + // TODO: properly handle errors due to XHR cancellation + return; }); } }, diff --git a/app/assets/javascripts/extensions/array.js b/app/assets/javascripts/extensions/array.js index f8256a8d26d..027222f804d 100644 --- a/app/assets/javascripts/extensions/array.js +++ b/app/assets/javascripts/extensions/array.js @@ -1,27 +1,11 @@ -/* eslint-disable no-extend-native, func-names, space-before-function-paren, space-infix-ops, strict, max-len */ +// TODO: remove this -'use strict'; - -Array.prototype.first = function() { +// eslint-disable-next-line no-extend-native +Array.prototype.first = function first() { return this[0]; }; -Array.prototype.last = function() { - return this[this.length-1]; -}; - -Array.prototype.find = Array.prototype.find || function(predicate, ...args) { - if (!this) throw new TypeError('Array.prototype.find called on null or undefined'); - if (typeof predicate !== 'function') throw new TypeError('predicate must be a function'); - - const list = Object(this); - const thisArg = args[1]; - let value = {}; - - for (let i = 0; i < list.length; i += 1) { - value = list[i]; - if (predicate.call(thisArg, value, i, list)) return value; - } - - return undefined; +// eslint-disable-next-line no-extend-native +Array.prototype.last = function last() { + return this[this.length - 1]; }; diff --git a/app/assets/javascripts/extensions/custom_event.js b/app/assets/javascripts/extensions/custom_event.js deleted file mode 100644 index abedae4c1c7..00000000000 --- a/app/assets/javascripts/extensions/custom_event.js +++ /dev/null @@ -1,12 +0,0 @@ -/* global CustomEvent */ -/* eslint-disable no-global-assign */ - -// Custom event support for IE -CustomEvent = function CustomEvent(event, parameters) { - const params = parameters || { bubbles: false, cancelable: false, detail: undefined }; - const evt = document.createEvent('CustomEvent'); - evt.initCustomEvent(event, params.bubbles, params.cancelable, params.detail); - return evt; -}; - -CustomEvent.prototype = window.Event.prototype; diff --git a/app/assets/javascripts/extensions/element.js b/app/assets/javascripts/extensions/element.js deleted file mode 100644 index 90ab79305a7..00000000000 --- a/app/assets/javascripts/extensions/element.js +++ /dev/null @@ -1,20 +0,0 @@ -/* global Element */ -/* eslint-disable consistent-return, max-len, no-empty, func-names */ - -Element.prototype.closest = Element.prototype.closest || function closest(selector, selectedElement = this) { - if (!selectedElement) return; - return selectedElement.matches(selector) ? selectedElement : Element.prototype.closest(selector, selectedElement.parentElement); -}; - -Element.prototype.matches = Element.prototype.matches || - Element.prototype.matchesSelector || - Element.prototype.mozMatchesSelector || - Element.prototype.msMatchesSelector || - Element.prototype.oMatchesSelector || - Element.prototype.webkitMatchesSelector || - function (s) { - const matches = (this.document || this.ownerDocument).querySelectorAll(s); - let i = matches.length - 1; - while (i >= 0 && matches.item(i) !== this) { i -= 1; } - return i > -1; - }; diff --git a/app/assets/javascripts/extensions/jquery.js b/app/assets/javascripts/extensions/jquery.js deleted file mode 100644 index 1a489b859e8..00000000000 --- a/app/assets/javascripts/extensions/jquery.js +++ /dev/null @@ -1,16 +0,0 @@ -/* eslint-disable func-names, space-before-function-paren, object-shorthand, comma-dangle, max-len */ -// Disable an element and add the 'disabled' Bootstrap class -(function() { - $.fn.extend({ - disable: function() { - return $(this).attr('disabled', 'disabled').addClass('disabled'); - } - }); - - // Enable an element and remove the 'disabled' Bootstrap class - $.fn.extend({ - enable: function() { - return $(this).removeAttr('disabled').removeClass('disabled'); - } - }); -}).call(window); diff --git a/app/assets/javascripts/extensions/object.js b/app/assets/javascripts/extensions/object.js deleted file mode 100644 index 70a2d765abd..00000000000 --- a/app/assets/javascripts/extensions/object.js +++ /dev/null @@ -1,26 +0,0 @@ -/* eslint-disable no-restricted-syntax */ - -// Adapted from https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/Object/assign#Polyfill -if (typeof Object.assign !== 'function') { - Object.assign = function assign(target, ...args) { - if (target == null) { // TypeError if undefined or null - throw new TypeError('Cannot convert undefined or null to object'); - } - - const to = Object(target); - - for (let index = 0; index < args.length; index += 1) { - const nextSource = args[index]; - - if (nextSource != null) { // Skip over if undefined or null - for (const nextKey in nextSource) { - // Avoid bugs when hasOwnProperty is shadowed - if (Object.prototype.hasOwnProperty.call(nextSource, nextKey)) { - to[nextKey] = nextSource[nextKey]; - } - } - } - } - return to; - }; -} diff --git a/app/assets/javascripts/extensions/string.js b/app/assets/javascripts/extensions/string.js deleted file mode 100644 index ae9662444b0..00000000000 --- a/app/assets/javascripts/extensions/string.js +++ /dev/null @@ -1,2 +0,0 @@ -import 'string.prototype.codepointat'; -import 'string.fromcodepoint'; diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js index e1a97070439..d37c812c1f7 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js @@ -162,6 +162,10 @@ } resetDropdowns() { + if (!this.currentDropdown) { + return; + } + // Force current dropdown to hide this.mapping[this.currentDropdown].reference.hideDropdown(); diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index 638fe744668..835e87a28d7 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -38,7 +38,8 @@ this.editTokenWrapper = this.editToken.bind(this); this.tokenChange = this.tokenChange.bind(this); - this.filteredSearchInput.form.addEventListener('submit', this.handleFormSubmit); + this.filteredSearchInputForm = this.filteredSearchInput.form; + this.filteredSearchInputForm.addEventListener('submit', this.handleFormSubmit); this.filteredSearchInput.addEventListener('input', this.setDropdownWrapper); this.filteredSearchInput.addEventListener('input', this.toggleClearSearchButtonWrapper); this.filteredSearchInput.addEventListener('input', this.handleInputPlaceholderWrapper); @@ -56,7 +57,7 @@ } unbindEvents() { - this.filteredSearchInput.form.removeEventListener('submit', this.handleFormSubmit); + this.filteredSearchInputForm.removeEventListener('submit', this.handleFormSubmit); this.filteredSearchInput.removeEventListener('input', this.setDropdownWrapper); this.filteredSearchInput.removeEventListener('input', this.toggleClearSearchButtonWrapper); this.filteredSearchInput.removeEventListener('input', this.handleInputPlaceholderWrapper); diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 604ed91627a..cf3e4ee77b6 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -16,17 +16,9 @@ import Sortable from 'vendor/Sortable'; import 'mousetrap'; import 'mousetrap/plugins/pause/mousetrap-pause'; import 'vendor/fuzzaldrin-plus'; -import promisePolyfill from 'es6-promise'; // extensions -import './extensions/string'; import './extensions/array'; -import './extensions/custom_event'; -import './extensions/element'; -import './extensions/jquery'; -import './extensions/object'; - -promisePolyfill.polyfill(); // expose common libraries as globals (TODO: remove these) window.jQuery = jQuery; diff --git a/app/assets/javascripts/monitoring/prometheus_graph.js b/app/assets/javascripts/monitoring/prometheus_graph.js index 9384fe3f276..71eb746edac 100644 --- a/app/assets/javascripts/monitoring/prometheus_graph.js +++ b/app/assets/javascripts/monitoring/prometheus_graph.js @@ -1,9 +1,11 @@ -/* eslint-disable no-new*/ +/* eslint-disable no-new */ +/* global Flash */ + import d3 from 'd3'; import _ from 'underscore'; import statusCodes from '~/lib/utils/http_status'; import '~/lib/utils/common_utils'; -import Flash from '~/flash'; +import '~/flash'; const prometheusGraphsContainer = '.prometheus-graph'; const metricsEndpoint = 'metrics.json'; diff --git a/changelogs/unreleased/use-corejs-polyfills.yml b/changelogs/unreleased/use-corejs-polyfills.yml new file mode 100644 index 00000000000..381f80c5c0d --- /dev/null +++ b/changelogs/unreleased/use-corejs-polyfills.yml @@ -0,0 +1,4 @@ +--- +title: Standardize on core-js for es2015 polyfills +merge_request: 9749 +author: diff --git a/package.json b/package.json index efa3a63e693..9652dd8f972 100644 --- a/package.json +++ b/package.json @@ -17,11 +17,11 @@ "babel-preset-stage-2": "^6.22.0", "bootstrap-sass": "^3.3.6", "compression-webpack-plugin": "^0.3.2", + "core-js": "^2.4.1", "d3": "^3.5.11", "document-register-element": "^1.3.0", "dropzone": "^4.2.0", "emoji-unicode-version": "^0.2.1", - "es6-promise": "^4.0.5", "jquery": "^2.2.1", "jquery-ujs": "^1.2.1", "js-cookie": "^2.1.3", @@ -31,8 +31,6 @@ "raw-loader": "^0.5.1", "select2": "3.5.2-browserify", "stats-webpack-plugin": "^0.4.3", - "string.fromcodepoint": "^0.2.1", - "string.prototype.codepointat": "^0.2.0", "timeago.js": "^2.0.5", "underscore": "^1.8.3", "vue": "^2.1.10", diff --git a/spec/javascripts/awards_handler_spec.js b/spec/javascripts/awards_handler_spec.js index 9a2978006aa..0a6e042b700 100644 --- a/spec/javascripts/awards_handler_spec.js +++ b/spec/javascripts/awards_handler_spec.js @@ -1,11 +1,8 @@ /* eslint-disable space-before-function-paren, no-var, one-var, one-var-declaration-per-line, no-unused-expressions, comma-dangle, new-parens, no-unused-vars, quotes, jasmine/no-spec-dupes, prefer-template, max-len */ -import promisePolyfill from 'es6-promise'; import Cookies from 'js-cookie'; import AwardsHandler from '~/awards_handler'; -promisePolyfill.polyfill(); - (function() { var awardsHandler, lazyAssert, urlRoot, openAndWaitForEmojiMenu; diff --git a/spec/javascripts/blob/create_branch_dropdown_spec.js b/spec/javascripts/blob/create_branch_dropdown_spec.js index dafb43761e0..c1179e572ae 100644 --- a/spec/javascripts/blob/create_branch_dropdown_spec.js +++ b/spec/javascripts/blob/create_branch_dropdown_spec.js @@ -1,5 +1,3 @@ -require('jquery'); -require('~/extensions/jquery.js'); require('~/gl_dropdown'); require('~/lib/utils/type_utility'); require('~/blob/create_branch_dropdown'); diff --git a/spec/javascripts/blob/target_branch_dropdown_spec.js b/spec/javascripts/blob/target_branch_dropdown_spec.js index 6f3eb4cc7eb..4fb79663c51 100644 --- a/spec/javascripts/blob/target_branch_dropdown_spec.js +++ b/spec/javascripts/blob/target_branch_dropdown_spec.js @@ -1,5 +1,3 @@ -require('jquery'); -require('~/extensions/jquery.js'); require('~/gl_dropdown'); require('~/lib/utils/type_utility'); require('~/blob/create_branch_dropdown'); diff --git a/spec/javascripts/boards/board_new_issue_spec.js b/spec/javascripts/boards/board_new_issue_spec.js index 22c9f12951b..4999933c0c1 100644 --- a/spec/javascripts/boards/board_new_issue_spec.js +++ b/spec/javascripts/boards/board_new_issue_spec.js @@ -8,7 +8,6 @@ import boardNewIssue from '~/boards/components/board_new_issue'; require('~/boards/models/list'); require('./mock_data'); -require('es6-promise').polyfill(); describe('Issue boards new issue form', () => { let vm; diff --git a/spec/javascripts/boards/boards_store_spec.js b/spec/javascripts/boards/boards_store_spec.js index 49a2ca4a78f..1d1069600fc 100644 --- a/spec/javascripts/boards/boards_store_spec.js +++ b/spec/javascripts/boards/boards_store_spec.js @@ -15,7 +15,6 @@ require('~/boards/models/user'); require('~/boards/services/board_service'); require('~/boards/stores/boards_store'); require('./mock_data'); -require('es6-promise').polyfill(); describe('Store', () => { beforeEach(() => { diff --git a/spec/javascripts/bootstrap_jquery_spec.js b/spec/javascripts/bootstrap_jquery_spec.js new file mode 100644 index 00000000000..48994b7c523 --- /dev/null +++ b/spec/javascripts/bootstrap_jquery_spec.js @@ -0,0 +1,42 @@ +/* eslint-disable space-before-function-paren, no-var */ + +import '~/commons/bootstrap'; + +(function() { + describe('Bootstrap jQuery extensions', function() { + describe('disable', function() { + beforeEach(function() { + return setFixtures(''); + }); + it('adds the disabled attribute', function() { + var $input; + $input = $('input').first(); + $input.disable(); + return expect($input).toHaveAttr('disabled', 'disabled'); + }); + return it('adds the disabled class', function() { + var $input; + $input = $('input').first(); + $input.disable(); + return expect($input).toHaveClass('disabled'); + }); + }); + return describe('enable', function() { + beforeEach(function() { + return setFixtures(''); + }); + it('removes the disabled attribute', function() { + var $input; + $input = $('input').first(); + $input.enable(); + return expect($input).not.toHaveAttr('disabled'); + }); + return it('removes the disabled class', function() { + var $input; + $input = $('input').first(); + $input.enable(); + return expect($input).not.toHaveClass('disabled'); + }); + }); + }); +}).call(window); diff --git a/spec/javascripts/extensions/array_spec.js b/spec/javascripts/extensions/array_spec.js index 60f6b9b78e3..4b871fe967d 100644 --- a/spec/javascripts/extensions/array_spec.js +++ b/spec/javascripts/extensions/array_spec.js @@ -18,28 +18,5 @@ require('~/extensions/array'); return expect(arr.last()).toBe(5); }); }); - - describe('find', function () { - beforeEach(() => { - this.arr = [0, 1, 2, 3, 4, 5]; - }); - - it('returns the item that first passes the predicate function', () => { - expect(this.arr.find(item => item === 2)).toBe(2); - }); - - it('returns undefined if no items pass the predicate function', () => { - expect(this.arr.find(item => item === 6)).not.toBeDefined(); - }); - - it('error when called on undefined or null', () => { - expect(Array.prototype.find.bind(undefined, item => item === 1)).toThrow(); - expect(Array.prototype.find.bind(null, item => item === 1)).toThrow(); - }); - - it('error when predicate is not a function', () => { - expect(Array.prototype.find.bind(this.arr, 1)).toThrow(); - }); - }); }); }).call(window); diff --git a/spec/javascripts/extensions/element_spec.js b/spec/javascripts/extensions/element_spec.js deleted file mode 100644 index 2d8a128ed33..00000000000 --- a/spec/javascripts/extensions/element_spec.js +++ /dev/null @@ -1,38 +0,0 @@ -require('~/extensions/element'); - -(() => { - describe('Element extensions', function () { - beforeEach(() => { - this.element = document.createElement('ul'); - }); - - describe('matches', () => { - it('returns true if element matches the selector', () => { - expect(this.element.matches('ul')).toBeTruthy(); - }); - - it("returns false if element doesn't match the selector", () => { - expect(this.element.matches('.not-an-element')).toBeFalsy(); - }); - }); - - describe('closest', () => { - beforeEach(() => { - this.childElement = document.createElement('li'); - this.element.appendChild(this.childElement); - }); - - it('returns the closest parent that matches the selector', () => { - expect(this.childElement.closest('ul').toString()).toBe(this.element.toString()); - }); - - it('returns itself if it matches the selector', () => { - expect(this.childElement.closest('li').toString()).toBe(this.childElement.toString()); - }); - - it('returns undefined if nothing matches the selector', () => { - expect(this.childElement.closest('.no-an-element')).toBeFalsy(); - }); - }); - }); -})(); diff --git a/spec/javascripts/extensions/jquery_spec.js b/spec/javascripts/extensions/jquery_spec.js deleted file mode 100644 index 096d3272eac..00000000000 --- a/spec/javascripts/extensions/jquery_spec.js +++ /dev/null @@ -1,42 +0,0 @@ -/* eslint-disable space-before-function-paren, no-var */ - -require('~/extensions/jquery'); - -(function() { - describe('jQuery extensions', function() { - describe('disable', function() { - beforeEach(function() { - return setFixtures(''); - }); - it('adds the disabled attribute', function() { - var $input; - $input = $('input').first(); - $input.disable(); - return expect($input).toHaveAttr('disabled', 'disabled'); - }); - return it('adds the disabled class', function() { - var $input; - $input = $('input').first(); - $input.disable(); - return expect($input).toHaveClass('disabled'); - }); - }); - return describe('enable', function() { - beforeEach(function() { - return setFixtures(''); - }); - it('removes the disabled attribute', function() { - var $input; - $input = $('input').first(); - $input.enable(); - return expect($input).not.toHaveAttr('disabled'); - }); - return it('removes the disabled class', function() { - var $input; - $input = $('input').first(); - $input.enable(); - return expect($input).not.toHaveClass('disabled'); - }); - }); - }); -}).call(window); diff --git a/spec/javascripts/extensions/object_spec.js b/spec/javascripts/extensions/object_spec.js deleted file mode 100644 index 2467ed78459..00000000000 --- a/spec/javascripts/extensions/object_spec.js +++ /dev/null @@ -1,25 +0,0 @@ -require('~/extensions/object'); - -describe('Object extensions', () => { - describe('assign', () => { - it('merges source object into target object', () => { - const targetObj = {}; - const sourceObj = { - foo: 'bar', - }; - Object.assign(targetObj, sourceObj); - expect(targetObj.foo).toBe('bar'); - }); - - it('merges object with the same properties', () => { - const targetObj = { - foo: 'bar', - }; - const sourceObj = { - foo: 'baz', - }; - Object.assign(targetObj, sourceObj); - expect(targetObj.foo).toBe('baz'); - }); - }); -}); diff --git a/spec/javascripts/filtered_search/filtered_search_manager_spec.js b/spec/javascripts/filtered_search/filtered_search_manager_spec.js index 81c1d81d181..ae9c263d1d7 100644 --- a/spec/javascripts/filtered_search/filtered_search_manager_spec.js +++ b/spec/javascripts/filtered_search/filtered_search_manager_spec.js @@ -41,7 +41,6 @@ const FilteredSearchSpecHelper = require('../helpers/filtered_search_spec_helper `); - spyOn(gl.FilteredSearchManager.prototype, 'cleanup').and.callFake(() => {}); spyOn(gl.FilteredSearchManager.prototype, 'loadSearchParamsFromURL').and.callFake(() => {}); spyOn(gl.FilteredSearchManager.prototype, 'tokenChange').and.callFake(() => {}); spyOn(gl.FilteredSearchDropdownManager.prototype, 'setDropdown').and.callFake(() => {}); @@ -54,6 +53,10 @@ const FilteredSearchSpecHelper = require('../helpers/filtered_search_spec_helper manager = new gl.FilteredSearchManager(); }); + afterEach(() => { + manager.cleanup(); + }); + describe('search', () => { const defaultParams = '?scope=all&utf8=✓&state=opened'; diff --git a/spec/javascripts/gl_emoji_spec.js b/spec/javascripts/gl_emoji_spec.js index 7ab0b37f2ec..9b44b25980c 100644 --- a/spec/javascripts/gl_emoji_spec.js +++ b/spec/javascripts/gl_emoji_spec.js @@ -1,6 +1,3 @@ -import '~/extensions/string'; -import '~/extensions/array'; - import { glEmojiTag } from '~/behaviors/gl_emoji'; import { isEmojiUnicodeSupported, diff --git a/spec/javascripts/monitoring/prometheus_graph_spec.js b/spec/javascripts/monitoring/prometheus_graph_spec.js index 823b4bab7fc..a3c1c5e1b7c 100644 --- a/spec/javascripts/monitoring/prometheus_graph_spec.js +++ b/spec/javascripts/monitoring/prometheus_graph_spec.js @@ -1,11 +1,8 @@ import 'jquery'; -import es6Promise from 'es6-promise'; import '~/lib/utils/common_utils'; import PrometheusGraph from '~/monitoring/prometheus_graph'; import { prometheusMockData } from './prometheus_mock_data'; -es6Promise.polyfill(); - describe('PrometheusGraph', () => { const fixtureName = 'static/environments/metrics.html.raw'; const prometheusGraphContainer = '.prometheus-graph'; diff --git a/spec/javascripts/polyfills/element_spec.js b/spec/javascripts/polyfills/element_spec.js new file mode 100644 index 00000000000..ecaaf1907ea --- /dev/null +++ b/spec/javascripts/polyfills/element_spec.js @@ -0,0 +1,36 @@ +import '~/commons/polyfills/element'; + +describe('Element polyfills', function () { + beforeEach(() => { + this.element = document.createElement('ul'); + }); + + describe('matches', () => { + it('returns true if element matches the selector', () => { + expect(this.element.matches('ul')).toBeTruthy(); + }); + + it("returns false if element doesn't match the selector", () => { + expect(this.element.matches('.not-an-element')).toBeFalsy(); + }); + }); + + describe('closest', () => { + beforeEach(() => { + this.childElement = document.createElement('li'); + this.element.appendChild(this.childElement); + }); + + it('returns the closest parent that matches the selector', () => { + expect(this.childElement.closest('ul').toString()).toBe(this.element.toString()); + }); + + it('returns itself if it matches the selector', () => { + expect(this.childElement.closest('li').toString()).toBe(this.childElement.toString()); + }); + + it('returns undefined if nothing matches the selector', () => { + expect(this.childElement.closest('.no-an-element')).toBeFalsy(); + }); + }); +}); diff --git a/spec/javascripts/right_sidebar_spec.js b/spec/javascripts/right_sidebar_spec.js index 4ac7e911740..285b7940174 100644 --- a/spec/javascripts/right_sidebar_spec.js +++ b/spec/javascripts/right_sidebar_spec.js @@ -1,8 +1,8 @@ /* eslint-disable space-before-function-paren, no-var, one-var, one-var-declaration-per-line, new-parens, no-return-assign, new-cap, vars-on-top, max-len */ /* global Sidebar */ -require('~/right_sidebar'); -require('~/extensions/jquery.js'); +import '~/commons/bootstrap'; +import '~/right_sidebar'; (function() { var $aside, $icon, $labelsIcon, $page, $toggle, assertSidebarState; diff --git a/spec/javascripts/shortcuts_issuable_spec.js b/spec/javascripts/shortcuts_issuable_spec.js index ffff643e371..9e19dabd0e3 100644 --- a/spec/javascripts/shortcuts_issuable_spec.js +++ b/spec/javascripts/shortcuts_issuable_spec.js @@ -31,13 +31,9 @@ require('~/shortcuts_issuable'); this.shortcut.replyWithSelectedText(); expect($(this.selector).val()).toBe(''); }); - it('triggers `input`', function() { - var focused = false; - $(this.selector).on('focus', function() { - focused = true; - }); + it('triggers `focus`', function() { this.shortcut.replyWithSelectedText(); - expect(focused).toBe(true); + expect(document.activeElement).toBe(document.querySelector(this.selector)); }); }); describe('with any selection', function() { diff --git a/yarn.lock b/yarn.lock index 55b8f1566ee..391b1c7eccf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1213,7 +1213,7 @@ cookie@0.3.1: version "0.3.1" resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.3.1.tgz#e7e0a1f9ef43b4c8ba925c5c5a96e806d16873bb" -core-js@^2.2.0, core-js@^2.4.0: +core-js@^2.2.0, core-js@^2.4.0, core-js@^2.4.1: version "2.4.1" resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.4.1.tgz#4de911e667b0eae9124e34254b53aea6fc618d3e" @@ -1553,7 +1553,7 @@ es6-map@^0.1.3: es6-symbol "~3.1.0" event-emitter "~0.3.4" -es6-promise@^4.0.5, es6-promise@~4.0.3: +es6-promise@~4.0.3: version "4.0.5" resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.0.5.tgz#7882f30adde5b240ccfa7f7d78c548330951ae42" @@ -4123,14 +4123,6 @@ string-width@^2.0.0: is-fullwidth-code-point "^2.0.0" strip-ansi "^3.0.0" -string.fromcodepoint@^0.2.1: - version "0.2.1" - resolved "https://registry.yarnpkg.com/string.fromcodepoint/-/string.fromcodepoint-0.2.1.tgz#8d978333c0bc92538f50f383e4888f3e5619d653" - -string.prototype.codepointat@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/string.prototype.codepointat/-/string.prototype.codepointat-0.2.0.tgz#6b26e9bd3afcaa7be3b4269b526de1b82000ac78" - string_decoder@^0.10.25, string_decoder@~0.10.x: version "0.10.31" resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94" -- cgit v1.2.1 From 0f9e3e2b58f172590d7e8664ebc16b6d143b588c Mon Sep 17 00:00:00 2001 From: blackst0ne Date: Tue, 14 Mar 2017 09:13:03 +1100 Subject: Add quick submit for snippet forms --- app/views/shared/snippets/_form.html.haml | 2 +- changelogs/unreleased/add_quick_submit_for_snippets_form.yml | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 changelogs/unreleased/add_quick_submit_for_snippets_form.yml diff --git a/app/views/shared/snippets/_form.html.haml b/app/views/shared/snippets/_form.html.haml index e7f7db73223..0296597b294 100644 --- a/app/views/shared/snippets/_form.html.haml +++ b/app/views/shared/snippets/_form.html.haml @@ -3,7 +3,7 @@ = page_specific_javascript_bundle_tag('snippet') .snippet-form-holder - = form_for @snippet, url: url, html: { class: "form-horizontal snippet-form js-requires-input" } do |f| + = form_for @snippet, url: url, html: { class: "form-horizontal snippet-form js-requires-input js-quick-submit" } do |f| = form_errors(@snippet) .form-group diff --git a/changelogs/unreleased/add_quick_submit_for_snippets_form.yml b/changelogs/unreleased/add_quick_submit_for_snippets_form.yml new file mode 100644 index 00000000000..088f1335796 --- /dev/null +++ b/changelogs/unreleased/add_quick_submit_for_snippets_form.yml @@ -0,0 +1,4 @@ +--- +title: Add quick submit for snippet forms +merge_request: 9911 +author: blackst0ne -- cgit v1.2.1 From e10810ded8457e45921934359bc1e4fcb35fa785 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 13 Mar 2017 17:46:21 -0500 Subject: Fix missing blob line permalink updater on blob:show See https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/9461#note_25288831 --- app/assets/javascripts/dispatcher.js | 44 +++++++++++++++++------------------- 1 file changed, 21 insertions(+), 23 deletions(-) diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index f0967d4f470..6d8174e199e 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -67,6 +67,25 @@ const UserCallout = require('./user_callout'); } path = page.split(':'); shortcut_handler = null; + + function initBlob() { + new LineHighlighter(); + + new BlobLinePermalinkUpdater( + document.querySelector('#blob-content-holder'), + '.diff-line-num[data-line-number]', + document.querySelectorAll('.js-data-file-blob-permalink-url, .js-blob-blame-link'), + ); + + shortcut_handler = new ShortcutsNavigation(); + fileBlobPermalinkUrlElement = document.querySelector('.js-data-file-blob-permalink-url'); + fileBlobPermalinkUrl = fileBlobPermalinkUrlElement && fileBlobPermalinkUrlElement.getAttribute('href'); + new ShortcutsBlob({ + skipResetBindings: true, + fileBlobPermalinkUrl, + }); + } + switch (page) { case 'sessions:new': new UsernameValidator(); @@ -259,34 +278,13 @@ const UserCallout = require('./user_callout'); break; case 'projects:blob:show': gl.TargetBranchDropDown.bootstrap(); - new LineHighlighter(); - shortcut_handler = new ShortcutsNavigation(); - fileBlobPermalinkUrlElement = document.querySelector('.js-data-file-blob-permalink-url'); - fileBlobPermalinkUrl = fileBlobPermalinkUrlElement && fileBlobPermalinkUrlElement.getAttribute('href'); - new ShortcutsBlob({ - skipResetBindings: true, - fileBlobPermalinkUrl, - }); + initBlob(); break; case 'projects:blob:edit': gl.TargetBranchDropDown.bootstrap(); break; case 'projects:blame:show': - new LineHighlighter(); - - new BlobLinePermalinkUpdater( - document.querySelector('#blob-content-holder'), - '.diff-line-num[data-line-number]', - document.querySelectorAll('.js-data-file-blob-permalink-url, .js-blob-blame-link'), - ); - - shortcut_handler = new ShortcutsNavigation(); - fileBlobPermalinkUrlElement = document.querySelector('.js-data-file-blob-permalink-url'); - fileBlobPermalinkUrl = fileBlobPermalinkUrlElement && fileBlobPermalinkUrlElement.getAttribute('href'); - new ShortcutsBlob({ - skipResetBindings: true, - fileBlobPermalinkUrl, - }); + initBlob(); break; case 'groups:labels:new': case 'groups:labels:edit': -- cgit v1.2.1 From 5111525a4a1dc6a4e5498875b9198c265d6ea288 Mon Sep 17 00:00:00 2001 From: Lee Matos Date: Mon, 13 Mar 2017 23:18:45 +0000 Subject: Update markdown.md example with asterisks and underscores for clarity --- doc/user/markdown.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/user/markdown.md b/doc/user/markdown.md index db06224bac2..97de428d11d 100644 --- a/doc/user/markdown.md +++ b/doc/user/markdown.md @@ -431,7 +431,7 @@ Emphasis, aka italics, with *asterisks* or _underscores_. Strong emphasis, aka bold, with **asterisks** or __underscores__. -Combined emphasis with **asterisks and _underscores_**. +Combined emphasis with **_asterisks and underscores_**. Strikethrough uses two tildes. ~~Scratch this.~~ ``` -- cgit v1.2.1 From db59e735ae9c30bfa1e9d0800b6edfaaf6981f2a Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Tue, 28 Feb 2017 21:59:55 -0500 Subject: Toggle project name if too long --- app/assets/javascripts/dispatcher.js | 5 +++ app/assets/javascripts/group_name.js | 40 ++++++++++++++++++++ app/assets/stylesheets/framework/header.scss | 22 +++++++++++ app/helpers/groups_helper.rb | 11 +++--- app/views/layouts/header/_default.html.haml | 2 +- ...187-project-name-cut-off-with-nested-groups.yml | 4 ++ spec/features/groups/group_name_toggle.rb | 44 ++++++++++++++++++++++ 7 files changed, 122 insertions(+), 6 deletions(-) create mode 100644 app/assets/javascripts/group_name.js create mode 100644 changelogs/unreleased/28187-project-name-cut-off-with-nested-groups.yml create mode 100644 spec/features/groups/group_name_toggle.rb diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index 7b9b9123c31..fcf3cb05e63 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -37,6 +37,7 @@ import PrometheusGraph from './monitoring/prometheus_graph'; // TODO: Maybe Make import Issue from './issue'; import BindInOut from './behaviors/bind_in_out'; +import GroupName from './group_name'; import GroupsList from './groups_list'; import ProjectsList from './projects_list'; import MiniPipelineGraph from './mini_pipeline_graph_dropdown'; @@ -342,6 +343,9 @@ const UserCallout = require('./user_callout'); shortcut_handler = new ShortcutsDashboardNavigation(); new UserCallout(); break; + case 'groups': + new GroupName(); + break; case 'profiles': new NotificationsForm(); new NotificationsDropdown(); @@ -349,6 +353,7 @@ const UserCallout = require('./user_callout'); case 'projects': new Project(); new ProjectAvatar(); + new GroupName(); switch (path[1]) { case 'compare': new CompareAutocomplete(); diff --git a/app/assets/javascripts/group_name.js b/app/assets/javascripts/group_name.js new file mode 100644 index 00000000000..6a028f299b1 --- /dev/null +++ b/app/assets/javascripts/group_name.js @@ -0,0 +1,40 @@ +const GROUP_LIMIT = 2; + +export default class GroupName { + constructor() { + this.titleContainer = document.querySelector('.title'); + this.groups = document.querySelectorAll('.group-path'); + this.groupTitle = document.querySelector('.group-title'); + this.toggle = null; + this.isHidden = false; + this.init(); + } + + init() { + if (this.groups.length > GROUP_LIMIT) { + this.groups[this.groups.length - 1].classList.remove('hidable'); + this.addToggle(); + } + this.render(); + } + + addToggle() { + const header = document.querySelector('.header-content'); + this.toggle = document.createElement('button'); + this.toggle.className = 'text-expander group-name-toggle'; + this.toggle.setAttribute('aria-label', 'Toggle full path'); + this.toggle.innerHTML = '...'; + this.toggle.addEventListener('click', this.toggleGroups.bind(this)); + header.insertBefore(this.toggle, this.titleContainer); + this.toggleGroups(); + } + + toggleGroups() { + this.isHidden = !this.isHidden; + this.groupTitle.classList.toggle('is-hidden'); + } + + render() { + this.titleContainer.classList.remove('initializing'); + } +} diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index 5d1aba4e529..6660a022260 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -164,11 +164,25 @@ header { } } + .group-name-toggle { + margin: 0 5px; + vertical-align: sub; + } + + .group-title { + &.is-hidden { + .hidable:not(:last-of-type) { + display: none; + } + } + } + .title { position: relative; padding-right: 20px; margin: 0; font-size: 18px; + max-width: 385px; display: inline-block; line-height: $header-height; font-weight: normal; @@ -178,6 +192,14 @@ header { vertical-align: top; white-space: nowrap; + &.initializing { + display: none; + } + + @media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) { + max-width: 300px; + } + @media (max-width: $screen-xs-max) { max-width: 190px; } diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index 926c9703628..a6014088e92 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -12,17 +12,18 @@ module GroupsHelper end def group_title(group, name = nil, url = nil) + @has_group_title = true full_title = '' group.ancestors.each do |parent| - full_title += link_to(simple_sanitize(parent.name), group_path(parent)) - full_title += ' / '.html_safe + full_title += link_to(simple_sanitize(parent.name), group_path(parent), class: 'group-path hidable') + full_title += ' / '.html_safe end - full_title += link_to(simple_sanitize(group.name), group_path(group)) - full_title += ' · '.html_safe + link_to(simple_sanitize(name), url) if name + full_title += link_to(simple_sanitize(group.name), group_path(group), class: 'group-path') + full_title += ' · '.html_safe + link_to(simple_sanitize(name), url, class: 'group-path') if name - content_tag :span do + content_tag :span, class: 'group-title' do full_title.html_safe end end diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index 6f4f2dbea3a..5fde5c2613e 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -67,7 +67,7 @@ = link_to root_path, class: 'home', title: 'Dashboard', id: 'logo' do = brand_header_logo - %h1.title= title + %h1.title{ class: ('initializing' if @has_group_title) }= title = yield :header_content diff --git a/changelogs/unreleased/28187-project-name-cut-off-with-nested-groups.yml b/changelogs/unreleased/28187-project-name-cut-off-with-nested-groups.yml new file mode 100644 index 00000000000..feca38ff083 --- /dev/null +++ b/changelogs/unreleased/28187-project-name-cut-off-with-nested-groups.yml @@ -0,0 +1,4 @@ +--- +title: Use toggle button to expand / collapse mulit-nested groups +merge_request: 9501 +author: diff --git a/spec/features/groups/group_name_toggle.rb b/spec/features/groups/group_name_toggle.rb new file mode 100644 index 00000000000..ada4ac66e04 --- /dev/null +++ b/spec/features/groups/group_name_toggle.rb @@ -0,0 +1,44 @@ +require 'spec_helper' + +feature 'Group name toggle', js: true do + let(:group) { create(:group) } + let(:nested_group_1) { create(:group, parent: group) } + let(:nested_group_2) { create(:group, parent: nested_group_1) } + let(:nested_group_3) { create(:group, parent: nested_group_2) } + + before do + login_as :user + end + + it 'is not present for less than 3 groups' do + visit group_path(group) + expect(page).not_to have_css('.group-name-toggle') + + visit group_path(nested_group_1) + expect(page).not_to have_css('.group-name-toggle') + end + + it 'is present for nested group of 3 or more in the namespace' do + visit group_path(nested_group_2) + expect(page).to have_css('.group-name-toggle') + + visit group_path(nested_group_3) + expect(page).to have_css('.group-name-toggle') + end + + context 'for group with at least 3 groups' do + before do + visit group_path(nested_group_2) + end + + it 'should show the full group namespace when toggled' do + expect(page).not_to have_content(group.name) + expect(page).to have_css('.group-path.hidable', visible: false) + + click_button '...' + + expect(page).to have_content(group.name) + expect(page).to have_css('.group-path.hidable', visible: true) + end + end +end -- cgit v1.2.1 From d48dda3c2ddbd260a828a1145f22834846353bc5 Mon Sep 17 00:00:00 2001 From: James Edwards-Jones Date: Tue, 14 Mar 2017 00:52:23 +0000 Subject: Fix 'ExecJS disabled' error on issues index Occurred in production when an issue had an associated MR --- app/views/shared/_issuable_meta_data.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/shared/_issuable_meta_data.html.haml b/app/views/shared/_issuable_meta_data.html.haml index 66310da5cd6..1d4fd71522d 100644 --- a/app/views/shared/_issuable_meta_data.html.haml +++ b/app/views/shared/_issuable_meta_data.html.haml @@ -6,7 +6,7 @@ - if issuable_mr > 0 %li - = image_tag('icon-merge-request-unmerged', class: 'icon-merge-request-unmerged') + = image_tag('icon-merge-request-unmerged.svg', class: 'icon-merge-request-unmerged') = issuable_mr - if upvotes > 0 -- cgit v1.2.1 From cbf1b656a464e0e544f7e559efed6851616e377f Mon Sep 17 00:00:00 2001 From: Filipa Lacerda Date: Tue, 14 Mar 2017 00:58:26 +0000 Subject: Use a button and a post request instead of UJS links - part 1 - Environments --- .../environments/components/environment.js | 76 ++++++++++++---------- .../environments/components/environment_actions.js | 56 ++++++++++++---- .../components/environment_external_url.js | 14 ++-- .../environments/components/environment_item.js | 34 ++++++---- .../components/environment_rollback.js | 49 ++++++++++++-- .../environments/components/environment_stop.js | 50 +++++++++++--- .../components/environment_terminal_button.js | 9 +-- .../environments/components/environments_table.js | 16 +++-- .../environments/environments_bundle.js | 2 +- app/assets/javascripts/environments/event_hub.js | 3 + .../folder/environments_folder_bundle.js | 2 +- .../folder/environments_folder_view.js | 19 +++--- .../environments/services/environments_service.js | 15 +++-- .../environments/stores/environments_store.js | 7 +- .../vue_shared/vue_resource_interceptor.js | 4 -- app/assets/stylesheets/pages/environments.scss | 8 +++ .../projects/environments/environments_spec.rb | 10 +-- .../environments/environment_actions_spec.js | 35 ++++++---- .../environments/environment_external_url_spec.js | 11 ++-- .../environments/environment_item_spec.js | 18 ++--- .../environments/environment_rollback_spec.js | 42 +++++++----- spec/javascripts/environments/environment_spec.js | 8 +-- .../environments/environment_stop_spec.js | 30 +++++---- .../environments/environment_table_spec.js | 8 ++- .../environment_terminal_button_spec.js | 24 +++++++ .../environments/environments_store_spec.js | 4 +- .../folder/environments_folder_view_spec.js | 8 +-- spec/javascripts/environments/mock_data.js | 12 +--- 28 files changed, 375 insertions(+), 199 deletions(-) create mode 100644 app/assets/javascripts/environments/event_hub.js create mode 100644 spec/javascripts/environments/environment_terminal_button_spec.js diff --git a/app/assets/javascripts/environments/components/environment.js b/app/assets/javascripts/environments/components/environment.js index 2cb48dde628..0923ce6b550 100644 --- a/app/assets/javascripts/environments/components/environment.js +++ b/app/assets/javascripts/environments/components/environment.js @@ -1,16 +1,17 @@ /* eslint-disable no-param-reassign, no-new */ /* global Flash */ +import EnvironmentsService from '../services/environments_service'; +import EnvironmentTable from './environments_table'; +import EnvironmentsStore from '../stores/environments_store'; +import eventHub from '../event_hub'; const Vue = window.Vue = require('vue'); window.Vue.use(require('vue-resource')); -const EnvironmentsService = require('../services/environments_service'); -const EnvironmentTable = require('./environments_table'); -const EnvironmentsStore = require('../stores/environments_store'); require('../../vue_shared/components/table_pagination'); require('../../lib/utils/common_utils'); require('../../vue_shared/vue_resource_interceptor'); -module.exports = Vue.component('environment-component', { +export default Vue.component('environment-component', { components: { 'environment-table': EnvironmentTable, @@ -66,33 +67,15 @@ module.exports = Vue.component('environment-component', { * Toggles loading property. */ created() { - const scope = gl.utils.getParameterByName('scope') || this.visibility; - const pageNumber = gl.utils.getParameterByName('page') || this.pageNumber; - - const endpoint = `${this.endpoint}?scope=${scope}&page=${pageNumber}`; - - const service = new EnvironmentsService(endpoint); - - this.isLoading = true; - - return service.get() - .then(resp => ({ - headers: resp.headers, - body: resp.json(), - })) - .then((response) => { - this.store.storeAvailableCount(response.body.available_count); - this.store.storeStoppedCount(response.body.stopped_count); - this.store.storeEnvironments(response.body.environments); - this.store.setPagination(response.headers); - }) - .then(() => { - this.isLoading = false; - }) - .catch(() => { - this.isLoading = false; - new Flash('An error occurred while fetching the environments.', 'alert'); - }); + this.service = new EnvironmentsService(this.endpoint); + + this.fetchEnvironments(); + + eventHub.$on('refreshEnvironments', this.fetchEnvironments); + }, + + beforeDestroyed() { + eventHub.$off('refreshEnvironments'); }, methods: { @@ -112,6 +95,32 @@ module.exports = Vue.component('environment-component', { gl.utils.visitUrl(param); return param; }, + + fetchEnvironments() { + const scope = gl.utils.getParameterByName('scope') || this.visibility; + const pageNumber = gl.utils.getParameterByName('page') || this.pageNumber; + + this.isLoading = true; + + return this.service.get(scope, pageNumber) + .then(resp => ({ + headers: resp.headers, + body: resp.json(), + })) + .then((response) => { + this.store.storeAvailableCount(response.body.available_count); + this.store.storeStoppedCount(response.body.stopped_count); + this.store.storeEnvironments(response.body.environments); + this.store.setPagination(response.headers); + }) + .then(() => { + this.isLoading = false; + }) + .catch(() => { + this.isLoading = false; + new Flash('An error occurred while fetching the environments.'); + }); + }, }, template: ` @@ -144,7 +153,7 @@ module.exports = Vue.component('environment-component', {
    - +
    + :can-read-environment="canReadEnvironmentParsed" + :service="service"/>
    [], }, + + service: { + type: Object, + required: true, + }, }, data() { - return { playIconSvg }; + return { + playIconSvg, + isLoading: false, + }; + }, + + methods: { + onClickAction(endpoint) { + this.isLoading = true; + + this.service.postAction(endpoint) + .then(() => { + this.isLoading = false; + eventHub.$emit('refreshEnvironments'); + }) + .catch(() => { + this.isLoading = false; + new Flash('An error occured while making the request.'); + }); + }, }, template: ` `, -}); +}; diff --git a/app/assets/javascripts/environments/components/environment_external_url.js b/app/assets/javascripts/environments/components/environment_external_url.js index 2599bba3c59..a554998f52c 100644 --- a/app/assets/javascripts/environments/components/environment_external_url.js +++ b/app/assets/javascripts/environments/components/environment_external_url.js @@ -1,9 +1,7 @@ /** * Renders the external url link in environments table. */ -const Vue = require('vue'); - -module.exports = Vue.component('external-url-component', { +export default { props: { externalUrl: { type: String, @@ -12,8 +10,12 @@ module.exports = Vue.component('external-url-component', { }, template: ` - - + + `, -}); +}; diff --git a/app/assets/javascripts/environments/components/environment_item.js b/app/assets/javascripts/environments/components/environment_item.js index 7f4e070b229..93919d41c60 100644 --- a/app/assets/javascripts/environments/components/environment_item.js +++ b/app/assets/javascripts/environments/components/environment_item.js @@ -1,13 +1,11 @@ -const Vue = require('vue'); -const Timeago = require('timeago.js'); - -require('../../lib/utils/text_utility'); -require('../../vue_shared/components/commit'); -const ActionsComponent = require('./environment_actions'); -const ExternalUrlComponent = require('./environment_external_url'); -const StopComponent = require('./environment_stop'); -const RollbackComponent = require('./environment_rollback'); -const TerminalButtonComponent = require('./environment_terminal_button'); +import Timeago from 'timeago.js'; +import ActionsComponent from './environment_actions'; +import ExternalUrlComponent from './environment_external_url'; +import StopComponent from './environment_stop'; +import RollbackComponent from './environment_rollback'; +import TerminalButtonComponent from './environment_terminal_button'; +import '../../lib/utils/text_utility'; +import '../../vue_shared/components/commit'; /** * Envrionment Item Component @@ -17,7 +15,7 @@ const TerminalButtonComponent = require('./environment_terminal_button'); const timeagoInstance = new Timeago(); -module.exports = Vue.component('environment-item', { +export default { components: { 'commit-component': gl.CommitComponent, @@ -46,6 +44,11 @@ module.exports = Vue.component('environment-item', { required: false, default: false, }, + + service: { + type: Object, + required: true, + }, }, computed: { @@ -489,22 +492,25 @@ module.exports = Vue.component('environment-item', {
    + :stop-url="model.stop_path" + :service="service"/> + :retry-url="retryUrl" + :service="service"/>
    `, -}); +}; diff --git a/app/assets/javascripts/environments/components/environment_rollback.js b/app/assets/javascripts/environments/components/environment_rollback.js index daf126eb4e8..baa15d9e5b5 100644 --- a/app/assets/javascripts/environments/components/environment_rollback.js +++ b/app/assets/javascripts/environments/components/environment_rollback.js @@ -1,10 +1,14 @@ +/* global Flash */ +/* eslint-disable no-new */ /** * Renders Rollback or Re deploy button in environments table depending - * of the provided property `isLastDeployment` + * of the provided property `isLastDeployment`. + * + * Makes a post request when the button is clicked. */ -const Vue = require('vue'); +import eventHub from '../event_hub'; -module.exports = Vue.component('rollback-component', { +export default { props: { retryUrl: { type: String, @@ -15,16 +19,49 @@ module.exports = Vue.component('rollback-component', { type: Boolean, default: true, }, + + service: { + type: Object, + required: true, + }, + }, + + data() { + return { + isLoading: false, + }; + }, + + methods: { + onClick() { + this.isLoading = true; + + this.service.postAction(this.retryUrl) + .then(() => { + this.isLoading = false; + eventHub.$emit('refreshEnvironments'); + }) + .catch(() => { + this.isLoading = false; + new Flash('An error occured while making the request.'); + }); + }, }, template: ` - + `, -}); +}; diff --git a/app/assets/javascripts/environments/components/environment_stop.js b/app/assets/javascripts/environments/components/environment_stop.js index 96983a19568..5404d647745 100644 --- a/app/assets/javascripts/environments/components/environment_stop.js +++ b/app/assets/javascripts/environments/components/environment_stop.js @@ -1,24 +1,56 @@ +/* global Flash */ +/* eslint-disable no-new, no-alert */ /** * Renders the stop "button" that allows stop an environment. * Used in environments table. */ -const Vue = require('vue'); +import eventHub from '../event_hub'; -module.exports = Vue.component('stop-component', { +export default { props: { stopUrl: { type: String, default: '', }, + + service: { + type: Object, + required: true, + }, + }, + + data() { + return { + isLoading: false, + }; + }, + + methods: { + onClick() { + if (confirm('Are you sure you want to stop this environment?')) { + this.isLoading = true; + + this.service.postAction(this.retryUrl) + .then(() => { + this.isLoading = false; + eventHub.$emit('refreshEnvironments'); + }) + .catch(() => { + this.isLoading = false; + new Flash('An error occured while making the request.', 'alert'); + }); + } + }, }, template: ` - + `, -}); +}; diff --git a/app/assets/javascripts/environments/components/environment_terminal_button.js b/app/assets/javascripts/environments/components/environment_terminal_button.js index e86607e78f4..66a71faa02f 100644 --- a/app/assets/javascripts/environments/components/environment_terminal_button.js +++ b/app/assets/javascripts/environments/components/environment_terminal_button.js @@ -2,13 +2,13 @@ * Renders a terminal button to open a web terminal. * Used in environments table. */ -const Vue = require('vue'); -const terminalIconSvg = require('icons/_icon_terminal.svg'); +import terminalIconSvg from 'icons/_icon_terminal.svg'; -module.exports = Vue.component('terminal-button-component', { +export default { props: { terminalPath: { type: String, + required: false, default: '', }, }, @@ -19,8 +19,9 @@ module.exports = Vue.component('terminal-button-component', { template: ` ${terminalIconSvg} `, -}); +}; diff --git a/app/assets/javascripts/environments/components/environments_table.js b/app/assets/javascripts/environments/components/environments_table.js index 4088d63be80..5f07b612b91 100644 --- a/app/assets/javascripts/environments/components/environments_table.js +++ b/app/assets/javascripts/environments/components/environments_table.js @@ -1,11 +1,9 @@ /** * Render environments table. */ -const Vue = require('vue'); -const EnvironmentItem = require('./environment_item'); - -module.exports = Vue.component('environment-table-component', { +import EnvironmentItem from './environment_item'; +export default { components: { 'environment-item': EnvironmentItem, }, @@ -28,6 +26,11 @@ module.exports = Vue.component('environment-table-component', { required: false, default: false, }, + + service: { + type: Object, + required: true, + }, }, template: ` @@ -48,9 +51,10 @@ module.exports = Vue.component('environment-table-component', { + :can-read-environment="canReadEnvironment" + :service="service"> `, -}); +}; diff --git a/app/assets/javascripts/environments/environments_bundle.js b/app/assets/javascripts/environments/environments_bundle.js index 7bbba91bc10..8d963b335cf 100644 --- a/app/assets/javascripts/environments/environments_bundle.js +++ b/app/assets/javascripts/environments/environments_bundle.js @@ -1,4 +1,4 @@ -const EnvironmentsComponent = require('./components/environment'); +import EnvironmentsComponent from './components/environment'; $(() => { window.gl = window.gl || {}; diff --git a/app/assets/javascripts/environments/event_hub.js b/app/assets/javascripts/environments/event_hub.js new file mode 100644 index 00000000000..0948c2e5352 --- /dev/null +++ b/app/assets/javascripts/environments/event_hub.js @@ -0,0 +1,3 @@ +import Vue from 'vue'; + +export default new Vue(); diff --git a/app/assets/javascripts/environments/folder/environments_folder_bundle.js b/app/assets/javascripts/environments/folder/environments_folder_bundle.js index d2ca465351a..f939eccf246 100644 --- a/app/assets/javascripts/environments/folder/environments_folder_bundle.js +++ b/app/assets/javascripts/environments/folder/environments_folder_bundle.js @@ -1,4 +1,4 @@ -const EnvironmentsFolderComponent = require('./environments_folder_view'); +import EnvironmentsFolderComponent from './environments_folder_view'; $(() => { window.gl = window.gl || {}; diff --git a/app/assets/javascripts/environments/folder/environments_folder_view.js b/app/assets/javascripts/environments/folder/environments_folder_view.js index 2a9d0492d7a..7abcf6dbbea 100644 --- a/app/assets/javascripts/environments/folder/environments_folder_view.js +++ b/app/assets/javascripts/environments/folder/environments_folder_view.js @@ -1,16 +1,16 @@ /* eslint-disable no-param-reassign, no-new */ /* global Flash */ +import EnvironmentsService from '../services/environments_service'; +import EnvironmentTable from '../components/environments_table'; +import EnvironmentsStore from '../stores/environments_store'; const Vue = window.Vue = require('vue'); window.Vue.use(require('vue-resource')); -const EnvironmentsService = require('../services/environments_service'); -const EnvironmentTable = require('../components/environments_table'); -const EnvironmentsStore = require('../stores/environments_store'); require('../../vue_shared/components/table_pagination'); require('../../lib/utils/common_utils'); require('../../vue_shared/vue_resource_interceptor'); -module.exports = Vue.component('environment-folder-view', { +export default Vue.component('environment-folder-view', { components: { 'environment-table': EnvironmentTable, @@ -88,11 +88,11 @@ module.exports = Vue.component('environment-folder-view', { const endpoint = `${this.endpoint}?scope=${scope}&page=${pageNumber}`; - const service = new EnvironmentsService(endpoint); + this.service = new EnvironmentsService(endpoint); this.isLoading = true; - return service.get() + return this.service.get() .then(resp => ({ headers: resp.headers, body: resp.json(), @@ -168,13 +168,12 @@ module.exports = Vue.component('environment-folder-view', { :can-read-environment="canReadEnvironmentParsed" :play-icon-svg="playIconSvg" :terminal-icon-svg="terminalIconSvg" - :commit-icon-svg="commitIconSvg"> - + :commit-icon-svg="commitIconSvg" + :service="service"/> - + :pageInfo="state.paginationInformation"/>
    diff --git a/app/assets/javascripts/environments/services/environments_service.js b/app/assets/javascripts/environments/services/environments_service.js index effc6c4c838..76296c83d11 100644 --- a/app/assets/javascripts/environments/services/environments_service.js +++ b/app/assets/javascripts/environments/services/environments_service.js @@ -1,13 +1,16 @@ -const Vue = require('vue'); +/* eslint-disable class-methods-use-this */ +import Vue from 'vue'; -class EnvironmentsService { +export default class EnvironmentsService { constructor(endpoint) { this.environments = Vue.resource(endpoint); } - get() { - return this.environments.get(); + get(scope, page) { + return this.environments.get({ scope, page }); } -} -module.exports = EnvironmentsService; + postAction(endpoint) { + return Vue.http.post(endpoint, {}, { emulateJSON: true }); + } +} diff --git a/app/assets/javascripts/environments/stores/environments_store.js b/app/assets/javascripts/environments/stores/environments_store.js index 15cd9bde08e..d3fe3872c56 100644 --- a/app/assets/javascripts/environments/stores/environments_store.js +++ b/app/assets/javascripts/environments/stores/environments_store.js @@ -1,11 +1,12 @@ -require('~/lib/utils/common_utils'); +import '~/lib/utils/common_utils'; + /** * Environments Store. * * Stores received environments, count of stopped environments and count of * available environments. */ -class EnvironmentsStore { +export default class EnvironmentsStore { constructor() { this.state = {}; this.state.environments = []; @@ -86,5 +87,3 @@ class EnvironmentsStore { return count; } } - -module.exports = EnvironmentsStore; diff --git a/app/assets/javascripts/vue_shared/vue_resource_interceptor.js b/app/assets/javascripts/vue_shared/vue_resource_interceptor.js index d3229f9f730..4157fefddc9 100644 --- a/app/assets/javascripts/vue_shared/vue_resource_interceptor.js +++ b/app/assets/javascripts/vue_shared/vue_resource_interceptor.js @@ -6,10 +6,6 @@ Vue.http.interceptors.push((request, next) => { Vue.activeResources = Vue.activeResources ? Vue.activeResources + 1 : 1; next((response) => { - if (typeof response.data === 'string') { - response.data = JSON.parse(response.data); - } - Vue.activeResources--; }); }); diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss index 0e2b8dba780..73a5da715f2 100644 --- a/app/assets/stylesheets/pages/environments.scss +++ b/app/assets/stylesheets/pages/environments.scss @@ -141,6 +141,14 @@ margin-right: 0; } } + + .no-btn { + border: none; + background: none; + outline: none; + width: 100%; + text-align: left; + } } } diff --git a/spec/features/projects/environments/environments_spec.rb b/spec/features/projects/environments/environments_spec.rb index 25f31b423b8..641e2cf7402 100644 --- a/spec/features/projects/environments/environments_spec.rb +++ b/spec/features/projects/environments/environments_spec.rb @@ -111,10 +111,8 @@ feature 'Environments page', :feature, :js do find('.js-dropdown-play-icon-container').click expect(page).to have_content(action.name.humanize) - expect { click_link(action.name.humanize) } + expect { find('.js-manual-action-link').click } .not_to change { Ci::Pipeline.count } - - expect(action.reload).to be_pending end scenario 'does show build name and id' do @@ -158,12 +156,6 @@ feature 'Environments page', :feature, :js do expect(page).to have_selector('.stop-env-link') end - scenario 'starts build when stop button clicked' do - find('.stop-env-link').click - - expect(page).to have_content('close_app') - end - context 'for reporter' do let(:role) { :reporter } diff --git a/spec/javascripts/environments/environment_actions_spec.js b/spec/javascripts/environments/environment_actions_spec.js index d50d45d295e..85b73f1d4e2 100644 --- a/spec/javascripts/environments/environment_actions_spec.js +++ b/spec/javascripts/environments/environment_actions_spec.js @@ -1,14 +1,16 @@ -const ActionsComponent = require('~/environments/components/environment_actions'); +import Vue from 'vue'; +import actionsComp from '~/environments/components/environment_actions'; describe('Actions Component', () => { - preloadFixtures('static/environments/element.html.raw'); + let ActionsComponent; + let actionsMock; + let spy; + let component; beforeEach(() => { - loadFixtures('static/environments/element.html.raw'); - }); + ActionsComponent = Vue.extend(actionsComp); - it('should render a dropdown with the provided actions', () => { - const actionsMock = [ + actionsMock = [ { name: 'bar', play_path: 'https://gitlab.com/play', @@ -19,18 +21,27 @@ describe('Actions Component', () => { }, ]; - const component = new ActionsComponent({ - el: document.querySelector('.test-dom-element'), + spy = jasmine.createSpy('spy').and.returnValue(Promise.resolve()); + component = new ActionsComponent({ propsData: { actions: actionsMock, + service: { + postAction: spy, + }, }, - }); + }).$mount(); + }); + it('should render a dropdown with the provided actions', () => { expect( component.$el.querySelectorAll('.dropdown-menu li').length, ).toEqual(actionsMock.length); - expect( - component.$el.querySelector('.dropdown-menu li a').getAttribute('href'), - ).toEqual(actionsMock[0].play_path); + }); + + it('should call the service when an action is clicked', () => { + component.$el.querySelector('.dropdown').click(); + component.$el.querySelector('.js-manual-action-link').click(); + + expect(spy).toHaveBeenCalledWith(actionsMock[0].play_path); }); }); diff --git a/spec/javascripts/environments/environment_external_url_spec.js b/spec/javascripts/environments/environment_external_url_spec.js index 393dbb5aae0..9af218a27ff 100644 --- a/spec/javascripts/environments/environment_external_url_spec.js +++ b/spec/javascripts/environments/environment_external_url_spec.js @@ -1,19 +1,20 @@ -const ExternalUrlComponent = require('~/environments/components/environment_external_url'); +import Vue from 'vue'; +import externalUrlComp from '~/environments/components/environment_external_url'; describe('External URL Component', () => { - preloadFixtures('static/environments/element.html.raw'); + let ExternalUrlComponent; + beforeEach(() => { - loadFixtures('static/environments/element.html.raw'); + ExternalUrlComponent = Vue.extend(externalUrlComp); }); it('should link to the provided externalUrl prop', () => { const externalURL = 'https://gitlab.com'; const component = new ExternalUrlComponent({ - el: document.querySelector('.test-dom-element'), propsData: { externalUrl: externalURL, }, - }); + }).$mount(); expect(component.$el.getAttribute('href')).toEqual(externalURL); expect(component.$el.querySelector('fa-external-link')).toBeDefined(); diff --git a/spec/javascripts/environments/environment_item_spec.js b/spec/javascripts/environments/environment_item_spec.js index 7fea80ed799..4d42de4d549 100644 --- a/spec/javascripts/environments/environment_item_spec.js +++ b/spec/javascripts/environments/environment_item_spec.js @@ -1,10 +1,12 @@ -window.timeago = require('timeago.js'); -const EnvironmentItem = require('~/environments/components/environment_item'); +import 'timeago.js'; +import Vue from 'vue'; +import environmentItemComp from '~/environments/components/environment_item'; describe('Environment item', () => { - preloadFixtures('static/environments/table.html.raw'); + let EnvironmentItem; + beforeEach(() => { - loadFixtures('static/environments/table.html.raw'); + EnvironmentItem = Vue.extend(environmentItemComp); }); describe('When item is folder', () => { @@ -21,13 +23,13 @@ describe('Environment item', () => { }; component = new EnvironmentItem({ - el: document.querySelector('tr#environment-row'), propsData: { model: mockItem, canCreateDeployment: false, canReadEnvironment: true, + service: {}, }, - }); + }).$mount(); }); it('Should render folder icon and name', () => { @@ -109,13 +111,13 @@ describe('Environment item', () => { }; component = new EnvironmentItem({ - el: document.querySelector('tr#environment-row'), propsData: { model: environment, canCreateDeployment: true, canReadEnvironment: true, + service: {}, }, - }); + }).$mount(); }); it('should render environment name', () => { diff --git a/spec/javascripts/environments/environment_rollback_spec.js b/spec/javascripts/environments/environment_rollback_spec.js index 4a596baad09..7cb39d9df03 100644 --- a/spec/javascripts/environments/environment_rollback_spec.js +++ b/spec/javascripts/environments/environment_rollback_spec.js @@ -1,47 +1,59 @@ -const RollbackComponent = require('~/environments/components/environment_rollback'); +import Vue from 'vue'; +import rollbackComp from '~/environments/components/environment_rollback'; describe('Rollback Component', () => { - preloadFixtures('static/environments/element.html.raw'); - const retryURL = 'https://gitlab.com/retry'; + let RollbackComponent; + let spy; beforeEach(() => { - loadFixtures('static/environments/element.html.raw'); + RollbackComponent = Vue.extend(rollbackComp); + spy = jasmine.createSpy('spy').and.returnValue(Promise.resolve()); }); - it('Should link to the provided retryUrl', () => { + it('Should render Re-deploy label when isLastDeployment is true', () => { const component = new RollbackComponent({ el: document.querySelector('.test-dom-element'), propsData: { retryUrl: retryURL, isLastDeployment: true, + service: { + postAction: spy, + }, }, - }); + }).$mount(); - expect(component.$el.getAttribute('href')).toEqual(retryURL); + expect(component.$el.querySelector('span').textContent).toContain('Re-deploy'); }); - it('Should render Re-deploy label when isLastDeployment is true', () => { + it('Should render Rollback label when isLastDeployment is false', () => { const component = new RollbackComponent({ el: document.querySelector('.test-dom-element'), propsData: { retryUrl: retryURL, - isLastDeployment: true, + isLastDeployment: false, + service: { + postAction: spy, + }, }, - }); + }).$mount(); - expect(component.$el.querySelector('span').textContent).toContain('Re-deploy'); + expect(component.$el.querySelector('span').textContent).toContain('Rollback'); }); - it('Should render Rollback label when isLastDeployment is false', () => { + it('should call the service when the button is clicked', () => { const component = new RollbackComponent({ - el: document.querySelector('.test-dom-element'), propsData: { retryUrl: retryURL, isLastDeployment: false, + service: { + postAction: spy, + }, }, - }); + }).$mount(); - expect(component.$el.querySelector('span').textContent).toContain('Rollback'); + component.$el.click(); + + expect(spy).toHaveBeenCalledWith(retryURL); }); }); diff --git a/spec/javascripts/environments/environment_spec.js b/spec/javascripts/environments/environment_spec.js index edd0cad32d0..9601575577e 100644 --- a/spec/javascripts/environments/environment_spec.js +++ b/spec/javascripts/environments/environment_spec.js @@ -1,7 +1,7 @@ -const Vue = require('vue'); -require('~/flash'); -const EnvironmentsComponent = require('~/environments/components/environment'); -const { environment } = require('./mock_data'); +import Vue from 'vue'; +import '~/flash'; +import EnvironmentsComponent from '~/environments/components/environment'; +import { environment } from './mock_data'; describe('Environment', () => { preloadFixtures('static/environments/environments.html.raw'); diff --git a/spec/javascripts/environments/environment_stop_spec.js b/spec/javascripts/environments/environment_stop_spec.js index 5ca65b1debc..8f79b88f3df 100644 --- a/spec/javascripts/environments/environment_stop_spec.js +++ b/spec/javascripts/environments/environment_stop_spec.js @@ -1,28 +1,34 @@ -const StopComponent = require('~/environments/components/environment_stop'); +import Vue from 'vue'; +import stopComp from '~/environments/components/environment_stop'; describe('Stop Component', () => { - preloadFixtures('static/environments/element.html.raw'); - - let stopURL; + let StopComponent; let component; + let spy; + const stopURL = '/stop'; beforeEach(() => { - loadFixtures('static/environments/element.html.raw'); + StopComponent = Vue.extend(stopComp); + spy = jasmine.createSpy('spy').and.returnValue(Promise.resolve()); + spyOn(window, 'confirm').and.returnValue(true); - stopURL = '/stop'; component = new StopComponent({ - el: document.querySelector('.test-dom-element'), propsData: { stopUrl: stopURL, + service: { + postAction: spy, + }, }, - }); + }).$mount(); }); - it('should link to the provided URL', () => { - expect(component.$el.getAttribute('href')).toEqual(stopURL); + it('should render a button to stop the environment', () => { + expect(component.$el.tagName).toEqual('BUTTON'); + expect(component.$el.getAttribute('title')).toEqual('Stop Environment'); }); - it('should have a data-confirm attribute', () => { - expect(component.$el.getAttribute('data-confirm')).toEqual('Are you sure you want to stop this environment?'); + it('should call the service when an action is clicked', () => { + component.$el.click(); + expect(spy).toHaveBeenCalled(); }); }); diff --git a/spec/javascripts/environments/environment_table_spec.js b/spec/javascripts/environments/environment_table_spec.js index be4330b5012..3df967848a7 100644 --- a/spec/javascripts/environments/environment_table_spec.js +++ b/spec/javascripts/environments/environment_table_spec.js @@ -1,4 +1,5 @@ -const EnvironmentTable = require('~/environments/components/environments_table'); +import Vue from 'vue'; +import environmentTableComp from '~/environments/components/environments_table'; describe('Environment item', () => { preloadFixtures('static/environments/element.html.raw'); @@ -16,14 +17,17 @@ describe('Environment item', () => { }, }; + const EnvironmentTable = Vue.extend(environmentTableComp); + const component = new EnvironmentTable({ el: document.querySelector('.test-dom-element'), propsData: { environments: [{ mockItem }], canCreateDeployment: false, canReadEnvironment: true, + service: {}, }, - }); + }).$mount(); expect(component.$el.tagName).toEqual('TABLE'); }); diff --git a/spec/javascripts/environments/environment_terminal_button_spec.js b/spec/javascripts/environments/environment_terminal_button_spec.js new file mode 100644 index 00000000000..b07aa4e1745 --- /dev/null +++ b/spec/javascripts/environments/environment_terminal_button_spec.js @@ -0,0 +1,24 @@ +import Vue from 'vue'; +import terminalComp from '~/environments/components/environment_terminal_button'; + +describe('Stop Component', () => { + let TerminalComponent; + let component; + const terminalPath = '/path'; + + beforeEach(() => { + TerminalComponent = Vue.extend(terminalComp); + + component = new TerminalComponent({ + propsData: { + terminalPath, + }, + }).$mount(); + }); + + it('should render a link to open a web terminal with the provided path', () => { + expect(component.$el.tagName).toEqual('A'); + expect(component.$el.getAttribute('title')).toEqual('Open web terminal'); + expect(component.$el.getAttribute('href')).toEqual(terminalPath); + }); +}); diff --git a/spec/javascripts/environments/environments_store_spec.js b/spec/javascripts/environments/environments_store_spec.js index 77e182b3830..115d84b50f5 100644 --- a/spec/javascripts/environments/environments_store_spec.js +++ b/spec/javascripts/environments/environments_store_spec.js @@ -1,5 +1,5 @@ -const Store = require('~/environments/stores/environments_store'); -const { environmentsList, serverData } = require('./mock_data'); +import Store from '~/environments/stores/environments_store'; +import { environmentsList, serverData } from './mock_data'; (() => { describe('Store', () => { diff --git a/spec/javascripts/environments/folder/environments_folder_view_spec.js b/spec/javascripts/environments/folder/environments_folder_view_spec.js index d1335b5b304..43a217a67f5 100644 --- a/spec/javascripts/environments/folder/environments_folder_view_spec.js +++ b/spec/javascripts/environments/folder/environments_folder_view_spec.js @@ -1,7 +1,7 @@ -const Vue = require('vue'); -require('~/flash'); -const EnvironmentsFolderViewComponent = require('~/environments/folder/environments_folder_view'); -const { environmentsList } = require('../mock_data'); +import Vue from 'vue'; +import '~/flash'; +import EnvironmentsFolderViewComponent from '~/environments/folder/environments_folder_view'; +import { environmentsList } from '../mock_data'; describe('Environments Folder View', () => { preloadFixtures('static/environments/environments_folder_view.html.raw'); diff --git a/spec/javascripts/environments/mock_data.js b/spec/javascripts/environments/mock_data.js index 5c395c6b2d8..30861481cc5 100644 --- a/spec/javascripts/environments/mock_data.js +++ b/spec/javascripts/environments/mock_data.js @@ -1,4 +1,4 @@ -const environmentsList = [ +export const environmentsList = [ { name: 'DEV', size: 1, @@ -30,7 +30,7 @@ const environmentsList = [ }, ]; -const serverData = [ +export const serverData = [ { name: 'DEV', size: 1, @@ -67,7 +67,7 @@ const serverData = [ }, ]; -const environment = { +export const environment = { name: 'DEV', size: 1, latest: { @@ -84,9 +84,3 @@ const environment = { updated_at: '2017-01-31T10:53:46.894Z', }, }; - -module.exports = { - environmentsList, - environment, - serverData, -}; -- cgit v1.2.1 From 29c5b31c192925e2a73e789ab6904168801ab260 Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Mon, 13 Mar 2017 22:16:29 -0700 Subject: Remove unused satellites config Note that the old migrations depend on 1_settings.rb, so we can't quite remove those completely. --- config/gitlab.yml.example | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example index 2bc39ea3f65..954809a882c 100644 --- a/config/gitlab.yml.example +++ b/config/gitlab.yml.example @@ -456,14 +456,6 @@ production: &base # 4. Advanced settings # ========================== - # GitLab Satellites - # - # Note for maintainers: keep the satellites.path setting until GitLab 9.0 at - # least. This setting is fed to 'rm -rf' in - # db/migrate/20151023144219_remove_satellites.rb - satellites: - path: /home/git/gitlab-satellites/ - ## Repositories settings repositories: # Paths where repositories can be stored. Give the canonicalized absolute pathname. @@ -581,8 +573,6 @@ test: # In order to setup it correctly you need to specify # your system username you use to run GitLab # user: YOUR_USERNAME - satellites: - path: tmp/tests/gitlab-satellites/ repositories: storages: default: -- cgit v1.2.1 From 0b20d850cb964da89f8b0df3c9e78a1cc2a86e1b Mon Sep 17 00:00:00 2001 From: Lin Jen-Shin Date: Tue, 14 Mar 2017 14:36:33 +0800 Subject: Fix for postgresql --- db/migrate/20161201160452_migrate_project_statistics.rb | 4 ++-- db/migrate/20161212142807_add_lower_path_index_to_routes.rb | 2 +- db/migrate/20170130204620_add_index_to_project_authorizations.rb | 4 ++++ db/migrate/20170305203726_add_owner_id_foreign_key.rb | 6 +++++- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/db/migrate/20161201160452_migrate_project_statistics.rb b/db/migrate/20161201160452_migrate_project_statistics.rb index 3ae3f2c159b..8386f7f9d4f 100644 --- a/db/migrate/20161201160452_migrate_project_statistics.rb +++ b/db/migrate/20161201160452_migrate_project_statistics.rb @@ -17,7 +17,7 @@ class MigrateProjectStatistics < ActiveRecord::Migration end def down - add_column_with_default :projects, :repository_size, :float, default: 0.0 - add_column_with_default :projects, :commit_count, :integer, default: 0 + add_column :projects, :repository_size, :float, default: 0.0 + add_column :projects, :commit_count, :integer, default: 0 end end diff --git a/db/migrate/20161212142807_add_lower_path_index_to_routes.rb b/db/migrate/20161212142807_add_lower_path_index_to_routes.rb index 6958500306f..53f4c6bbb18 100644 --- a/db/migrate/20161212142807_add_lower_path_index_to_routes.rb +++ b/db/migrate/20161212142807_add_lower_path_index_to_routes.rb @@ -17,6 +17,6 @@ class AddLowerPathIndexToRoutes < ActiveRecord::Migration def down return unless Gitlab::Database.postgresql? - remove_index :routes, name: :index_on_routes_lower_path + remove_index :routes, name: :index_on_routes_lower_path if index_exists?(:routes, name: :index_on_routes_lower_path) end end diff --git a/db/migrate/20170130204620_add_index_to_project_authorizations.rb b/db/migrate/20170130204620_add_index_to_project_authorizations.rb index e9a0aee4d6a..a8c504f265a 100644 --- a/db/migrate/20170130204620_add_index_to_project_authorizations.rb +++ b/db/migrate/20170130204620_add_index_to_project_authorizations.rb @@ -8,4 +8,8 @@ class AddIndexToProjectAuthorizations < ActiveRecord::Migration def up add_concurrent_index(:project_authorizations, :project_id) end + + def down + remove_index(:project_authorizations, :project_id) + end end diff --git a/db/migrate/20170305203726_add_owner_id_foreign_key.rb b/db/migrate/20170305203726_add_owner_id_foreign_key.rb index 3eece0e2eb5..5fbdc45f1a7 100644 --- a/db/migrate/20170305203726_add_owner_id_foreign_key.rb +++ b/db/migrate/20170305203726_add_owner_id_foreign_key.rb @@ -5,7 +5,11 @@ class AddOwnerIdForeignKey < ActiveRecord::Migration disable_ddl_transaction! - def change + def up add_concurrent_foreign_key :ci_triggers, :users, column: :owner_id, on_delete: :cascade end + + def down + remove_foreign_key :ci_triggers, column: :owner_id + end end -- cgit v1.2.1 From 77d93d33820b9771b906c2d2bd708b4b910aa9e9 Mon Sep 17 00:00:00 2001 From: Achilleas Pipinellis Date: Tue, 14 Mar 2017 09:53:28 +0100 Subject: Add missing steps of Pages source installation [ci skip] --- doc/administration/pages/source.md | 82 ++++++++++++++++++++++++++++---------- 1 file changed, 62 insertions(+), 20 deletions(-) diff --git a/doc/administration/pages/source.md b/doc/administration/pages/source.md index f6f50e2c571..b4588f8b43c 100644 --- a/doc/administration/pages/source.md +++ b/doc/administration/pages/source.md @@ -1,5 +1,9 @@ # GitLab Pages administration for source installations +>**Note:** +Before attempting to enable GitLab Pages, first make sure you have +[installed GitLab](../../install/installation.md) successfully. + This is the documentation for configuring a GitLab Pages when you have installed GitLab from source and not using the Omnibus packages. @@ -13,7 +17,33 @@ Pages to the latest supported version. ## Overview -[Read the Omnibus overview section.](index.md#overview) +GitLab Pages makes use of the [GitLab Pages daemon], a simple HTTP server +written in Go that can listen on an external IP address and provide support for +custom domains and custom certificates. It supports dynamic certificates through +SNI and exposes pages using HTTP2 by default. +You are encouraged to read its [README][pages-readme] to fully understand how +it works. + +--- + +In the case of [custom domains](#custom-domains) (but not +[wildcard domains](#wildcard-domains)), the Pages daemon needs to listen on +ports `80` and/or `443`. For that reason, there is some flexibility in the way +which you can set it up: + +1. Run the Pages daemon in the same server as GitLab, listening on a secondary IP. +1. Run the Pages daemon in a separate server. In that case, the + [Pages path](#change-storage-path) must also be present in the server that + the Pages daemon is installed, so you will have to share it via network. +1. Run the Pages daemon in the same server as GitLab, listening on the same IP + but on different ports. In that case, you will have to proxy the traffic with + a loadbalancer. If you choose that route note that you should use TCP load + balancing for HTTPS. If you use TLS-termination (HTTPS-load balancing) the + pages will not be able to be served with user provided certificates. For + HTTP it's OK to use HTTP or TCP load balancing. + +In this document, we will proceed assuming the first option. If you are not +supporting custom domains a secondary IP is not needed. ## Prerequisites @@ -75,7 +105,7 @@ The Pages daemon doesn't listen to the outside world. cd /home/git sudo -u git -H git clone https://gitlab.com/gitlab-org/gitlab-pages.git cd gitlab-pages - sudo -u git -H git checkout v0.2.4 + sudo -u git -H git checkout v0.3.2 sudo -u git -H make ``` @@ -100,14 +130,21 @@ The Pages daemon doesn't listen to the outside world. https: false ``` -1. Copy the `gitlab-pages-ssl` Nginx configuration file: +1. Edit `/etc/default/gitlab` and set `gitlab_pages_enabled` to `true` in + order to enable the pages daemon. In `gitlab_pages_options` the + `-pages-domain` must match the `host` setting that you set above. - ```bash - sudo cp lib/support/nginx/gitlab-pages-ssl /etc/nginx/sites-available/gitlab-pages-ssl.conf - sudo ln -sf /etc/nginx/sites-{available,enabled}/gitlab-pages-ssl.conf ``` + gitlab_pages_enabled=true + gitlab_pages_options="-pages-domain example.io -pages-root $app_root/shared/pages -listen-proxy 127.0.0.1:8090 + ``` + +1. Copy the `gitlab-pages` Nginx configuration file: - Replace `gitlab-pages-ssl` with `gitlab-pages` if you are not using SSL. + ```bash + sudo cp lib/support/nginx/gitlab-pages /etc/nginx/sites-available/gitlab-pages.conf + sudo ln -sf /etc/nginx/sites-{available,enabled}/gitlab-pages.conf + ``` 1. Restart NGINX 1. [Restart GitLab][restart] @@ -131,7 +168,7 @@ outside world. cd /home/git sudo -u git -H git clone https://gitlab.com/gitlab-org/gitlab-pages.git cd gitlab-pages - sudo -u git -H git checkout v0.2.4 + sudo -u git -H git checkout v0.3.2 sudo -u git -H make ``` @@ -149,6 +186,17 @@ outside world. https: true ``` +1. Edit `/etc/default/gitlab` and set `gitlab_pages_enabled` to `true` in + order to enable the pages daemon. In `gitlab_pages_options` the + `-pages-domain` must match the `host` setting that you set above. + The `-root-cert` and `-root-key` settings are the wildcard TLS certificates + of the `example.io` domain: + + ``` + gitlab_pages_enabled=true + gitlab_pages_options="-pages-domain example.io -pages-root $app_root/shared/pages -listen-proxy 127.0.0.1:8090 -root-cert /path/to/example.io.crt -root-key /path/to/example.io.key + ``` + 1. Copy the `gitlab-pages-ssl` Nginx configuration file: ```bash @@ -156,12 +204,9 @@ outside world. sudo ln -sf /etc/nginx/sites-{available,enabled}/gitlab-pages-ssl.conf ``` - Replace `gitlab-pages-ssl` with `gitlab-pages` if you are not using SSL. - 1. Restart NGINX 1. [Restart GitLab][restart] - ## Advanced configuration In addition to the wildcard domains, you can also have the option to configure @@ -189,7 +234,7 @@ world. Custom domains are supported, but no TLS. cd /home/git sudo -u git -H git clone https://gitlab.com/gitlab-org/gitlab-pages.git cd gitlab-pages - sudo -u git -H git checkout v0.2.4 + sudo -u git -H git checkout v0.3.2 sudo -u git -H make ``` @@ -224,12 +269,10 @@ world. Custom domains are supported, but no TLS. 1. Copy the `gitlab-pages-ssl` Nginx configuration file: ```bash - sudo cp lib/support/nginx/gitlab-pages-ssl /etc/nginx/sites-available/gitlab-pages-ssl.conf - sudo ln -sf /etc/nginx/sites-{available,enabled}/gitlab-pages-ssl.conf + sudo cp lib/support/nginx/gitlab-pages /etc/nginx/sites-available/gitlab-pages.conf + sudo ln -sf /etc/nginx/sites-{available,enabled}/gitlab-pages.conf ``` - Replace `gitlab-pages-ssl` with `gitlab-pages` if you are not using SSL. - 1. Edit all GitLab related configs in `/etc/nginx/site-available/` and replace `0.0.0.0` with `1.1.1.1`, where `1.1.1.1` the primary IP where GitLab listens to. @@ -257,7 +300,7 @@ world. Custom domains and TLS are supported. cd /home/git sudo -u git -H git clone https://gitlab.com/gitlab-org/gitlab-pages.git cd gitlab-pages - sudo -u git -H git checkout v0.2.4 + sudo -u git -H git checkout v0.3.2 sudo -u git -H make ``` @@ -300,8 +343,6 @@ world. Custom domains and TLS are supported. sudo ln -sf /etc/nginx/sites-{available,enabled}/gitlab-pages-ssl.conf ``` - Replace `gitlab-pages-ssl` with `gitlab-pages` if you are not using SSL. - 1. Edit all GitLab related configs in `/etc/nginx/site-available/` and replace `0.0.0.0` with `1.1.1.1`, where `1.1.1.1` the primary IP where GitLab listens to. @@ -392,5 +433,6 @@ than GitLab to prevent XSS attacks. [pages-userguide]: ../../user/project/pages/index.md [reconfigure]: ../restart_gitlab.md#omnibus-gitlab-reconfigure [restart]: ../restart_gitlab.md#installations-from-source -[gitlab-pages]: https://gitlab.com/gitlab-org/gitlab-pages/tree/v0.2.4 +[gitlab-pages]: https://gitlab.com/gitlab-org/gitlab-pages/tree/v0.3.2 +[gl-example]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/lib/support/init.d/gitlab.default.example [shared runners]: ../../ci/runners/README.md -- cgit v1.2.1 From 4cbe05e8029c32e68719b4b8445c6ab5677c519d Mon Sep 17 00:00:00 2001 From: Xiaogang Wen Date: Tue, 14 Mar 2017 09:09:56 +0000 Subject: Patch 15 --- doc/integration/github.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/integration/github.md b/doc/integration/github.md index cea85f073cc..4b0d33334bd 100644 --- a/doc/integration/github.md +++ b/doc/integration/github.md @@ -19,7 +19,7 @@ GitHub will generate an application ID and secret key for you to use. - Application name: This can be anything. Consider something like `'s GitLab` or `'s GitLab` or something else descriptive. - Homepage URL: The URL to your GitLab installation. 'https://gitlab.company.com' - Application description: Fill this in if you wish. - - Authorization callback URL is 'http(s)://${YOUR_DOMAIN}' + - Authorization callback URL is 'http(s)://${YOUR_DOMAIN}'. Please make sure the port is included if your Gitlab instance is not configured on default port. 1. Select "Register application". 1. You should now see a Client ID and Client Secret near the top right of the page (see screenshot). -- cgit v1.2.1 From e8c8ea8408de8241f003d76a25cdc773877e98e1 Mon Sep 17 00:00:00 2001 From: Achilleas Pipinellis Date: Tue, 14 Mar 2017 10:28:11 +0100 Subject: Include instructions to update /etc/default/gitlab We were missing some info on updating /etc/default/gitlab In particular, changes needed to be made in order for Pages to work, see https://gitlab.com/gitlab-org/gitlab-ce/issues/29372 --- doc/update/8.16-to-8.17.md | 14 +++++++++++++- doc/update/8.17-to-9.0.md | 13 +++++++++++-- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/doc/update/8.16-to-8.17.md b/doc/update/8.16-to-8.17.md index 954109ba18f..74ffe0bc846 100644 --- a/doc/update/8.16-to-8.17.md +++ b/doc/update/8.16-to-8.17.md @@ -139,7 +139,7 @@ sudo -u git -H git checkout v4.1.1 #### New configuration options for `gitlab.yml` -There are new configuration options available for [`gitlab.yml`][yaml]. View them with the command below and apply them manually to your current `gitlab.yml`: +There might be new configuration options available for [`gitlab.yml`][yaml]. View them with the command below and apply them manually to your current `gitlab.yml`: ```sh cd /home/git/gitlab @@ -195,6 +195,16 @@ See [smtp_settings.rb.sample] as an example. #### Init script +There might be new configuration options available for [`gitlab.default.example`][gl-example]. +You need to update this file if you want to [enable GitLab Pages][pages-admin]. +View them with the command below and apply them manually to your current `/etc/default/gitlab`: + +```sh +cd /home/git/gitlab + +git diff origin/8-16-stable:lib/support/init.d/gitlab.default.example origin/8-17-stable:lib/support/init.d/gitlab.default.example +``` + Ensure you're still up-to-date with the latest init script changes: ```bash @@ -254,3 +264,5 @@ sudo -u git -H bundle exec rake gitlab:backup:restore RAILS_ENV=production If you have more than one backup `*.tar` file(s) please add `BACKUP=timestamp_of_backup` to the command above. [yaml]: https://gitlab.com/gitlab-org/gitlab-ce/blob/8-17-stable/config/gitlab.yml.example +[gl-example]: https://gitlab.com/gitlab-org/gitlab-ce/blob/8-17-stable/lib/support/init.d/gitlab.default.example +[pages-admin]: ../administration/pages/source.md diff --git a/doc/update/8.17-to-9.0.md b/doc/update/8.17-to-9.0.md index 1fe38cf8d2a..626507c0482 100644 --- a/doc/update/8.17-to-9.0.md +++ b/doc/update/8.17-to-9.0.md @@ -149,7 +149,7 @@ sudo -u git -H git checkout v5.0.0 #### New configuration options for `gitlab.yml` -There are new configuration options available for [`gitlab.yml`][yaml]. View them with the command below and apply them manually to your current `gitlab.yml`: +There might be configuration options available for [`gitlab.yml`][yaml]. View them with the command below and apply them manually to your current `gitlab.yml`: ```sh cd /home/git/gitlab @@ -189,7 +189,7 @@ Update your current configuration as follows, replacing with your storages names **For Omnibus installations** -1. Upate your `/etc/gitlab/gitlab.rb`, from +1. Update your `/etc/gitlab/gitlab.rb`, from ```ruby git_data_dirs({ @@ -260,6 +260,14 @@ See [smtp_settings.rb.sample] as an example. #### Init script +There might be new configuration options available for [`gitlab.default.example`][gl-example]. View them with the command below and apply them manually to your current `/etc/default/gitlab`: + +```sh +cd /home/git/gitlab + +git diff origin/8-17-stable:lib/support/init.d/gitlab.default.example origin/9-0-stable:lib/support/init.d/gitlab.default.example +``` + Ensure you're still up-to-date with the latest init script changes: ```bash @@ -319,3 +327,4 @@ sudo -u git -H bundle exec rake gitlab:backup:restore RAILS_ENV=production If you have more than one backup `*.tar` file(s) please add `BACKUP=timestamp_of_backup` to the command above. [yaml]: https://gitlab.com/gitlab-org/gitlab-ce/blob/9-0-stable/config/gitlab.yml.example +[gl-example]: https://gitlab.com/gitlab-org/gitlab-ce/blob/9-0-stable/lib/support/init.d/gitlab.default.example -- cgit v1.2.1 From 435458d2b14eadd1768b7b0a14f5966633f02f83 Mon Sep 17 00:00:00 2001 From: Sam Rose Date: Mon, 6 Mar 2017 00:03:21 -0500 Subject: Update API on frontend to use v4 Use options object to pass params for project endpoint --- app/assets/javascripts/api.js | 10 +++++----- app/assets/javascripts/project_select.js | 4 ++-- app/assets/javascripts/search.js | 2 +- app/views/help/ui.html.haml | 2 +- lib/gitlab/gon_helper.rb | 2 +- spec/javascripts/project_title_spec.js | 2 +- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index a0946eb392a..e5f36c84987 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -51,15 +51,15 @@ var Api = { }); }, // Return projects list. Filtered by query - projects: function(query, order, callback) { + projects: function(query, options, callback) { var url = Api.buildUrl(Api.projectsPath); return $.ajax({ url: url, - data: { + data: $.extend({ search: query, - order_by: order, - per_page: 20 - }, + per_page: 20, + membership: true + }, options), dataType: "json" }).done(function(projects) { return callback(projects); diff --git a/app/assets/javascripts/project_select.js b/app/assets/javascripts/project_select.js index f80e765ce30..3c1c1e7dceb 100644 --- a/app/assets/javascripts/project_select.js +++ b/app/assets/javascripts/project_select.js @@ -35,7 +35,7 @@ if (this.groupId) { return Api.groupProjects(this.groupId, term, projectsCallback); } else { - return Api.projects(term, orderBy, projectsCallback); + return Api.projects(term, { order_by: orderBy }, projectsCallback); } }, url: function(project) { @@ -84,7 +84,7 @@ if (_this.groupId) { return Api.groupProjects(_this.groupId, query.term, projectsCallback); } else { - return Api.projects(query.term, _this.orderBy, projectsCallback); + return Api.projects(query.term, { order_by: _this.orderBy }, projectsCallback); } }; })(this), diff --git a/app/assets/javascripts/search.js b/app/assets/javascripts/search.js index e66418beeab..15f5963353a 100644 --- a/app/assets/javascripts/search.js +++ b/app/assets/javascripts/search.js @@ -47,7 +47,7 @@ fields: ['name'] }, data: function(term, callback) { - return Api.projects(term, 'id', function(data) { + return Api.projects(term, { order_by: 'id' }, function(data) { data.unshift({ name_with_namespace: 'Any' }); diff --git a/app/views/help/ui.html.haml b/app/views/help/ui.html.haml index 87f9b503989..1fb2c6271ad 100644 --- a/app/views/help/ui.html.haml +++ b/app/views/help/ui.html.haml @@ -410,7 +410,7 @@ :javascript $('#js-project-dropdown').glDropdown({ data: function (term, callback) { - Api.projects(term, "last_activity_at", function (data) { + Api.projects(term, { order_by: 'last_activity_at' }, function (data) { callback(data); }); }, diff --git a/lib/gitlab/gon_helper.rb b/lib/gitlab/gon_helper.rb index 6c275a8d5de..5ab84266b7d 100644 --- a/lib/gitlab/gon_helper.rb +++ b/lib/gitlab/gon_helper.rb @@ -1,7 +1,7 @@ module Gitlab module GonHelper def add_gon_variables - gon.api_version = 'v3' # v4 Is not officially released yet, therefore can't be considered as "frozen" + gon.api_version = 'v4' gon.default_avatar_url = URI.join(Gitlab.config.gitlab.url, ActionController::Base.helpers.image_path('no_avatar.png')).to_s gon.max_file_size = current_application_settings.max_attachment_size gon.asset_host = ActionController::Base.asset_host diff --git a/spec/javascripts/project_title_spec.js b/spec/javascripts/project_title_spec.js index 69d9587771f..3a1d4e2440f 100644 --- a/spec/javascripts/project_title_spec.js +++ b/spec/javascripts/project_title_spec.js @@ -26,7 +26,7 @@ require('~/project'); var fakeAjaxResponse = function fakeAjaxResponse(req) { var d; expect(req.url).toBe('/api/v3/projects.json?simple=true'); - expect(req.data).toEqual({ search: '', order_by: 'last_activity_at', per_page: 20 }); + expect(req.data).toEqual({ search: '', order_by: 'last_activity_at', per_page: 20, membership: true }); d = $.Deferred(); d.resolve(this.projects_data); return d.promise(); -- cgit v1.2.1 From 6bffc17f7d04f6ecd99d896ed299060724032a3b Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Tue, 14 Mar 2017 12:24:53 +0100 Subject: Add changelog entry for project status caching fix --- changelogs/unreleased/fix-gb-dashboard-commit-status-caching.yml | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelogs/unreleased/fix-gb-dashboard-commit-status-caching.yml diff --git a/changelogs/unreleased/fix-gb-dashboard-commit-status-caching.yml b/changelogs/unreleased/fix-gb-dashboard-commit-status-caching.yml new file mode 100644 index 00000000000..4db684c40b2 --- /dev/null +++ b/changelogs/unreleased/fix-gb-dashboard-commit-status-caching.yml @@ -0,0 +1,4 @@ +--- +title: Resolve project pipeline status caching problem on dashboard +merge_request: 9895 +author: -- cgit v1.2.1 From c7cecae616702a46430ed41e283912ddc22f2612 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 14 Mar 2017 11:32:58 +0000 Subject: added eventhub to emit update tokens event --- app/assets/javascripts/boards/boards_bundle.js | 5 +++-- app/assets/javascripts/boards/components/issue_card_inner.js | 4 +++- app/assets/javascripts/boards/eventhub.js | 3 +++ 3 files changed, 9 insertions(+), 3 deletions(-) create mode 100644 app/assets/javascripts/boards/eventhub.js diff --git a/app/assets/javascripts/boards/boards_bundle.js b/app/assets/javascripts/boards/boards_bundle.js index 4d60fedaeb8..3874c2819a5 100644 --- a/app/assets/javascripts/boards/boards_bundle.js +++ b/app/assets/javascripts/boards/boards_bundle.js @@ -3,6 +3,7 @@ /* global BoardService */ import FilteredSearchBoards from './filtered_search_boards'; +import eventHub from './eventhub'; window.Vue = require('vue'); window.Vue.use(require('vue-resource')); @@ -65,10 +66,10 @@ $(() => { this.filterManager = new FilteredSearchBoards(Store.filter, true); // Listen for updateTokens event - this.$on('updateTokens', this.updateTokens); + eventHub.$on('updateTokens', this.updateTokens); }, beforeDestroy() { - this.$off('updateTokens', this.updateTokens); + eventHub.$off('updateTokens', this.updateTokens); }, mounted () { Store.disabled = this.disabled; diff --git a/app/assets/javascripts/boards/components/issue_card_inner.js b/app/assets/javascripts/boards/components/issue_card_inner.js index 3d57ec429c6..69e30cec4c5 100644 --- a/app/assets/javascripts/boards/components/issue_card_inner.js +++ b/app/assets/javascripts/boards/components/issue_card_inner.js @@ -1,4 +1,6 @@ /* global Vue */ +import eventHub from '../eventhub'; + (() => { const Store = gl.issueBoards.BoardsStore; @@ -54,7 +56,7 @@ Store.updateFiltersUrl(); - gl.IssueBoardsApp.$emit('updateTokens'); + eventHub.$emit('updateTokens'); }, labelStyle(label) { return { diff --git a/app/assets/javascripts/boards/eventhub.js b/app/assets/javascripts/boards/eventhub.js new file mode 100644 index 00000000000..0948c2e5352 --- /dev/null +++ b/app/assets/javascripts/boards/eventhub.js @@ -0,0 +1,3 @@ +import Vue from 'vue'; + +export default new Vue(); -- cgit v1.2.1 From c9fbbb3ae2c7f0eb44b0f973155d68e678149544 Mon Sep 17 00:00:00 2001 From: Lin Jen-Shin Date: Tue, 14 Mar 2017 19:56:37 +0800 Subject: Disable rubocop for down method --- db/migrate/20161201160452_migrate_project_statistics.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/db/migrate/20161201160452_migrate_project_statistics.rb b/db/migrate/20161201160452_migrate_project_statistics.rb index 8386f7f9d4f..82fbdf02444 100644 --- a/db/migrate/20161201160452_migrate_project_statistics.rb +++ b/db/migrate/20161201160452_migrate_project_statistics.rb @@ -16,6 +16,7 @@ class MigrateProjectStatistics < ActiveRecord::Migration remove_column :projects, :commit_count end + # rubocop: disable Migration/AddColumn def down add_column :projects, :repository_size, :float, default: 0.0 add_column :projects, :commit_count, :integer, default: 0 -- cgit v1.2.1 From 1324a6b3050a121eae722258302ce46bf2636ac1 Mon Sep 17 00:00:00 2001 From: Jacob Vosmaer Date: Tue, 14 Mar 2017 13:03:17 +0100 Subject: Use google-protobuf 3.2.0.1 This sub-patch release extends support in pre-compiled libraries down to glibc 2.12. https://github.com/google/protobuf/issues/2783 Closes https://gitlab.com/gitlab-org/gitlab-ce/issues/29084 --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index c60c045a4c2..e38f45b8e98 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -304,7 +304,7 @@ GEM multi_json (~> 1.10) retriable (~> 1.4) signet (~> 0.6) - google-protobuf (3.2.0) + google-protobuf (3.2.0.1) googleauth (0.5.1) faraday (~> 0.9) jwt (~> 1.4) -- cgit v1.2.1 From f67d8eb1da269150764224cea1807195cdf2ffb5 Mon Sep 17 00:00:00 2001 From: Lin Jen-Shin Date: Tue, 14 Mar 2017 20:03:22 +0800 Subject: Drop the index only for postgresql, because mysql cannot simply drop the index without dropping the corresponding foreign key, and we certainly don't want to drop the foreign key here. --- db/migrate/20170130204620_add_index_to_project_authorizations.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/db/migrate/20170130204620_add_index_to_project_authorizations.rb b/db/migrate/20170130204620_add_index_to_project_authorizations.rb index a8c504f265a..629b49436e3 100644 --- a/db/migrate/20170130204620_add_index_to_project_authorizations.rb +++ b/db/migrate/20170130204620_add_index_to_project_authorizations.rb @@ -10,6 +10,7 @@ class AddIndexToProjectAuthorizations < ActiveRecord::Migration end def down - remove_index(:project_authorizations, :project_id) + remove_index(:project_authorizations, :project_id) if + Gitlab::Database.postgresql? end end -- cgit v1.2.1 From 96fe1856da46517b028fe0ddac89f314f25c8855 Mon Sep 17 00:00:00 2001 From: Valery Sizov Date: Thu, 9 Mar 2017 16:51:20 +0200 Subject: Fix relative position calculation --- app/models/concerns/relative_positioning.rb | 20 ++++++++++------- spec/models/concerns/relative_positioning_spec.rb | 27 +++++++++++++++++++++++ 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/app/models/concerns/relative_positioning.rb b/app/models/concerns/relative_positioning.rb index 603f2dd7e5d..ec23c8a08fb 100644 --- a/app/models/concerns/relative_positioning.rb +++ b/app/models/concerns/relative_positioning.rb @@ -3,6 +3,7 @@ module RelativePositioning MIN_POSITION = 0 MAX_POSITION = Gitlab::Database::MAX_INT_VALUE + DISTANCE = 500 included do after_save :save_positionable_neighbours @@ -49,7 +50,9 @@ module RelativePositioning pos_before = before.relative_position pos_after = after.relative_position - if pos_after && (pos_before == pos_after) + # We can't insert an issue between two other if the distance is 1 or 0 + # so we need to handle this collision properly + if pos_after && (pos_after - pos_before).abs <= 1 self.relative_position = pos_before before.move_before(self) after.move_after(self) @@ -75,19 +78,20 @@ module RelativePositioning private # This method takes two integer values (positions) and - # calculates some random position between them. The range is huge as - # the maximum integer value is 2147483647. Ideally, the calculated value would be - # exactly between those terminating values, but this will introduce possibility of a race condition - # so two or more issues can get the same value, we want to avoid that and we also want to avoid - # using a lock here. If we have two issues with distance more than one thousand, we are OK. - # Given the huge range of possible values that integer can fit we shoud never face a problem. + # calculates the position between them. The range is huge as + # the maximum integer value is 2147483647. We are incrementing position by 1000 every time + # when we have enough space. If distance is less then 500 we are calculating an average number def position_between(pos_before, pos_after) pos_before ||= MIN_POSITION pos_after ||= MAX_POSITION pos_before, pos_after = [pos_before, pos_after].sort - rand(pos_before.next..pos_after.pred) + if pos_after - pos_before > DISTANCE * 2 + pos_before + DISTANCE + else + pos_before + (pos_after - pos_before) / 2 + end end def save_positionable_neighbours diff --git a/spec/models/concerns/relative_positioning_spec.rb b/spec/models/concerns/relative_positioning_spec.rb index 69906382545..12f44bfe0a5 100644 --- a/spec/models/concerns/relative_positioning_spec.rb +++ b/spec/models/concerns/relative_positioning_spec.rb @@ -100,5 +100,32 @@ describe Issue, 'RelativePositioning' do expect(new_issue.relative_position).to be > issue.relative_position expect(issue.relative_position).to be < issue1.relative_position end + + it 'positions issues between other two if distance is 1' do + issue1.update relative_position: issue.relative_position + 1 + + new_issue.move_between(issue, issue1) + + expect(new_issue.relative_position).to be > issue.relative_position + expect(issue.relative_position).to be < issue1.relative_position + end + + it 'positions issue closer to before-issue if distance is big enough' do + issue.update relative_position: 100 + issue1.update relative_position: 6000 + + new_issue.move_between(issue, issue1) + + expect(new_issue.relative_position).to eq(100 + RelativePositioning::DISTANCE) + end + + it 'positions issue in the middle of other two if distance is not big enough' do + issue.update relative_position: 100 + issue1.update relative_position: 400 + + new_issue.move_between(issue, issue1) + + expect(new_issue.relative_position).to eq(250) + end end end -- cgit v1.2.1 From 67686e38fdb1d9c427a39ff1862af691ccd4e598 Mon Sep 17 00:00:00 2001 From: Valery Sizov Date: Thu, 9 Mar 2017 19:23:36 +0200 Subject: Added migration to reset existing relative_position for issues --- ...20170309171644_reset_relative_position_for_issue.rb | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 db/post_migrate/20170309171644_reset_relative_position_for_issue.rb diff --git a/db/post_migrate/20170309171644_reset_relative_position_for_issue.rb b/db/post_migrate/20170309171644_reset_relative_position_for_issue.rb new file mode 100644 index 00000000000..ce4be131d40 --- /dev/null +++ b/db/post_migrate/20170309171644_reset_relative_position_for_issue.rb @@ -0,0 +1,18 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class ResetRelativePositionForIssue < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def up + execute <<-EOS + UPDATE issues SET relative_position = NULL + WHERE issues.relative_position IS NOT NULL; + EOS + end + + def down + end +end -- cgit v1.2.1 From 5670777735a615b511c3282e8fc79b67c74669bc Mon Sep 17 00:00:00 2001 From: Valery Sizov Date: Fri, 10 Mar 2017 17:12:31 +0200 Subject: [Issue sorting] Filling positions preferable in the middle --- app/models/concerns/relative_positioning.rb | 23 +++++++------ spec/models/concerns/relative_positioning_spec.rb | 40 +++++++++++++++-------- 2 files changed, 39 insertions(+), 24 deletions(-) diff --git a/app/models/concerns/relative_positioning.rb b/app/models/concerns/relative_positioning.rb index ec23c8a08fb..f8ab16a9f4c 100644 --- a/app/models/concerns/relative_positioning.rb +++ b/app/models/concerns/relative_positioning.rb @@ -2,6 +2,7 @@ module RelativePositioning extend ActiveSupport::Concern MIN_POSITION = 0 + START_POSITION = Gitlab::Database::MAX_INT_VALUE / 2 MAX_POSITION = Gitlab::Database::MAX_INT_VALUE DISTANCE = 500 @@ -9,10 +10,6 @@ module RelativePositioning after_save :save_positionable_neighbours end - def min_relative_position - self.class.in_projects(project.id).minimum(:relative_position) - end - def max_relative_position self.class.in_projects(project.id).maximum(:relative_position) end @@ -27,7 +24,7 @@ module RelativePositioning maximum(:relative_position) end - prev_pos || MIN_POSITION + prev_pos end def next_relative_position @@ -40,7 +37,7 @@ module RelativePositioning minimum(:relative_position) end - next_pos || MAX_POSITION + next_pos end def move_between(before, after) @@ -72,7 +69,7 @@ module RelativePositioning end def move_to_end - self.relative_position = position_between(max_relative_position, MAX_POSITION) + self.relative_position = position_between(max_relative_position || START_POSITION, MAX_POSITION) end private @@ -87,10 +84,16 @@ module RelativePositioning pos_before, pos_after = [pos_before, pos_after].sort - if pos_after - pos_before > DISTANCE * 2 - pos_before + DISTANCE + if pos_after - pos_before < DISTANCE * 2 + (pos_after + pos_before) / 2 else - pos_before + (pos_after - pos_before) / 2 + if pos_before == MIN_POSITION + pos_after - DISTANCE + elsif pos_after == MAX_POSITION + pos_before + DISTANCE + else + (pos_after + pos_before) / 2 + end end end diff --git a/spec/models/concerns/relative_positioning_spec.rb b/spec/models/concerns/relative_positioning_spec.rb index 12f44bfe0a5..fbae9efcd98 100644 --- a/spec/models/concerns/relative_positioning_spec.rb +++ b/spec/models/concerns/relative_positioning_spec.rb @@ -12,12 +12,6 @@ describe Issue, 'RelativePositioning' do end end - describe '#min_relative_position' do - it 'returns maximum position' do - expect(issue.min_relative_position).to eq issue.relative_position - end - end - describe '#max_relative_position' do it 'returns maximum position' do expect(issue.max_relative_position).to eq issue1.relative_position @@ -29,8 +23,8 @@ describe Issue, 'RelativePositioning' do expect(issue1.prev_relative_position).to eq issue.relative_position end - it 'returns minimum position if there is no issue above' do - expect(issue.prev_relative_position).to eq RelativePositioning::MIN_POSITION + it 'returns nil if there is no issue above' do + expect(issue.prev_relative_position).to eq nil end end @@ -39,8 +33,8 @@ describe Issue, 'RelativePositioning' do expect(issue.next_relative_position).to eq issue1.relative_position end - it 'returns next position if there is no issue below' do - expect(issue1.next_relative_position).to eq RelativePositioning::MAX_POSITION + it 'returns nil if there is no issue below' do + expect(issue1.next_relative_position).to eq nil end end @@ -110,15 +104,33 @@ describe Issue, 'RelativePositioning' do expect(issue.relative_position).to be < issue1.relative_position end - it 'positions issue closer to before-issue if distance is big enough' do - issue.update relative_position: 100 - issue1.update relative_position: 6000 + it 'positions issue in the middle of other two if distance is big enough' do + issue.update relative_position: 6000 + issue1.update relative_position: 10000 new_issue.move_between(issue, issue1) - expect(new_issue.relative_position).to eq(100 + RelativePositioning::DISTANCE) + expect(new_issue.relative_position).to eq(8000) + end + + it 'positions issue closer to the middle if we are at the very top' do + issue1.update relative_position: 6000 + + new_issue.move_between(nil, issue1) + + expect(new_issue.relative_position).to eq(6000 - RelativePositioning::DISTANCE) + end + + it 'positions issue closer to the middle if we are at the very bottom' do + issue.update relative_position: 6000 + issue1.update relative_position: nil + + new_issue.move_between(issue, nil) + + expect(new_issue.relative_position).to eq(6000 + RelativePositioning::DISTANCE) end + it 'positions issue in the middle of other two if distance is not big enough' do issue.update relative_position: 100 issue1.update relative_position: 400 -- cgit v1.2.1 From b84723ac8bf8572c3d261980ab053dda52bc78dd Mon Sep 17 00:00:00 2001 From: Valery Sizov Date: Fri, 10 Mar 2017 17:17:55 +0200 Subject: [Issue Sorting] Improve migration --- .../20170309171644_reset_relative_position_for_issue.rb | 7 +++---- spec/models/concerns/relative_positioning_spec.rb | 1 - 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/db/post_migrate/20170309171644_reset_relative_position_for_issue.rb b/db/post_migrate/20170309171644_reset_relative_position_for_issue.rb index ce4be131d40..b61dd7cfc61 100644 --- a/db/post_migrate/20170309171644_reset_relative_position_for_issue.rb +++ b/db/post_migrate/20170309171644_reset_relative_position_for_issue.rb @@ -7,10 +7,9 @@ class ResetRelativePositionForIssue < ActiveRecord::Migration DOWNTIME = false def up - execute <<-EOS - UPDATE issues SET relative_position = NULL - WHERE issues.relative_position IS NOT NULL; - EOS + update_column_in_batches(:issues, :relative_position, nil) do |table, query| + query.where(table[:relative_position].not_eq(nil)) + end end def down diff --git a/spec/models/concerns/relative_positioning_spec.rb b/spec/models/concerns/relative_positioning_spec.rb index fbae9efcd98..ebbf14fb5ba 100644 --- a/spec/models/concerns/relative_positioning_spec.rb +++ b/spec/models/concerns/relative_positioning_spec.rb @@ -130,7 +130,6 @@ describe Issue, 'RelativePositioning' do expect(new_issue.relative_position).to eq(6000 + RelativePositioning::DISTANCE) end - it 'positions issue in the middle of other two if distance is not big enough' do issue.update relative_position: 100 issue1.update relative_position: 400 -- cgit v1.2.1 From e752d6d157321f9f10d70cdef1ce1992e263634f Mon Sep 17 00:00:00 2001 From: Valery Sizov Date: Fri, 10 Mar 2017 19:04:37 +0200 Subject: [Issue sorting]Addressed review comments --- .flayignore | 1 + app/models/concerns/relative_positioning.rb | 77 ++++++++++++++++------- spec/models/concerns/relative_positioning_spec.rb | 66 ++++++++++++++++++- 3 files changed, 119 insertions(+), 25 deletions(-) diff --git a/.flayignore b/.flayignore index fc64b0b5892..47597025115 100644 --- a/.flayignore +++ b/.flayignore @@ -2,3 +2,4 @@ lib/gitlab/sanitizers/svg/whitelist.rb lib/gitlab/diff/position_tracer.rb app/policies/project_policy.rb +app/models/concerns/relative_positioning.rb diff --git a/app/models/concerns/relative_positioning.rb b/app/models/concerns/relative_positioning.rb index f8ab16a9f4c..f1d8532a6d6 100644 --- a/app/models/concerns/relative_positioning.rb +++ b/app/models/concerns/relative_positioning.rb @@ -4,7 +4,7 @@ module RelativePositioning MIN_POSITION = 0 START_POSITION = Gitlab::Database::MAX_INT_VALUE / 2 MAX_POSITION = Gitlab::Database::MAX_INT_VALUE - DISTANCE = 500 + IDEAL_DISTANCE = 500 included do after_save :save_positionable_neighbours @@ -44,55 +44,86 @@ module RelativePositioning return move_after(before) unless after return move_before(after) unless before + # If there is no place to insert an issue we need to create one by moving the before issue closer + # to its predecessor. This process will recursively move all the predecessors until we have a place + if (after.relative_position - before.relative_position) < 2 + before.move_before + @positionable_neighbours = [before] + end + + self.relative_position = position_between(before.relative_position, after.relative_position) + end + + def move_after(before = self) pos_before = before.relative_position - pos_after = after.relative_position + pos_after = before.next_relative_position - # We can't insert an issue between two other if the distance is 1 or 0 - # so we need to handle this collision properly - if pos_after && (pos_after - pos_before).abs <= 1 - self.relative_position = pos_before - before.move_before(self) - after.move_after(self) + if before.shift_after? + issue_to_move = self.class.in_projects(project.id).find_by!(relative_position: pos_after) + issue_to_move.move_after + @positionable_neighbours = [issue_to_move] - @positionable_neighbours = [before, after] - else - self.relative_position = position_between(pos_before, pos_after) + pos_after = issue_to_move.relative_position end - end - def move_before(after) - self.relative_position = position_between(after.prev_relative_position, after.relative_position) + self.relative_position = position_between(pos_before, pos_after) end - def move_after(before) - self.relative_position = position_between(before.relative_position, before.next_relative_position) + def move_before(after = self) + pos_after = after.relative_position + pos_before = after.prev_relative_position + + if after.shift_before? + issue_to_move = self.class.in_projects(project.id).find_by!(relative_position: pos_before) + issue_to_move.move_before + @positionable_neighbours = [issue_to_move] + + pos_before = issue_to_move.relative_position + end + + self.relative_position = position_between(pos_before, pos_after) end def move_to_end self.relative_position = position_between(max_relative_position || START_POSITION, MAX_POSITION) end + # Indicates if there is an issue that should be shifted to free the place + def shift_after? + next_pos = next_relative_position + next_pos && (next_pos - relative_position) == 1 + end + + # Indicates if there is an issue that should be shifted to free the place + def shift_before? + prev_pos = prev_relative_position + prev_pos && (relative_position - prev_pos) == 1 + end + private # This method takes two integer values (positions) and # calculates the position between them. The range is huge as - # the maximum integer value is 2147483647. We are incrementing position by 1000 every time - # when we have enough space. If distance is less then 500 we are calculating an average number + # the maximum integer value is 2147483647. We are incrementing position by IDEAL_DISTANCE * 2 every time + # when we have enough space. If distance is less then IDEAL_DISTANCE we are calculating an average number def position_between(pos_before, pos_after) pos_before ||= MIN_POSITION pos_after ||= MAX_POSITION pos_before, pos_after = [pos_before, pos_after].sort - if pos_after - pos_before < DISTANCE * 2 - (pos_after + pos_before) / 2 + halfway = (pos_after + pos_before) / 2 + distance_to_halfway = pos_after - halfway + + if distance_to_halfway < IDEAL_DISTANCE + halfway else if pos_before == MIN_POSITION - pos_after - DISTANCE + pos_after - IDEAL_DISTANCE elsif pos_after == MAX_POSITION - pos_before + DISTANCE + pos_before + IDEAL_DISTANCE else - (pos_after + pos_before) / 2 + halfway end end end diff --git a/spec/models/concerns/relative_positioning_spec.rb b/spec/models/concerns/relative_positioning_spec.rb index ebbf14fb5ba..255b584a85e 100644 --- a/spec/models/concerns/relative_positioning_spec.rb +++ b/spec/models/concerns/relative_positioning_spec.rb @@ -66,6 +66,34 @@ describe Issue, 'RelativePositioning' do end end + describe '#shift_after?' do + it 'returns true' do + issue.update(relative_position: issue1.relative_position - 1) + + expect(issue.shift_after?).to be_truthy + end + + it 'returns false' do + issue.update(relative_position: issue1.relative_position - 2) + + expect(issue.shift_after?).to be_falsey + end + end + + describe '#shift_before?' do + it 'returns true' do + issue.update(relative_position: issue1.relative_position + 1) + + expect(issue.shift_before?).to be_truthy + end + + it 'returns false' do + issue.update(relative_position: issue1.relative_position + 2) + + expect(issue.shift_before?).to be_falsey + end + end + describe '#move_between' do it 'positions issue between two other' do new_issue.move_between(issue, issue1) @@ -118,7 +146,7 @@ describe Issue, 'RelativePositioning' do new_issue.move_between(nil, issue1) - expect(new_issue.relative_position).to eq(6000 - RelativePositioning::DISTANCE) + expect(new_issue.relative_position).to eq(6000 - RelativePositioning::IDEAL_DISTANCE) end it 'positions issue closer to the middle if we are at the very bottom' do @@ -127,7 +155,7 @@ describe Issue, 'RelativePositioning' do new_issue.move_between(issue, nil) - expect(new_issue.relative_position).to eq(6000 + RelativePositioning::DISTANCE) + expect(new_issue.relative_position).to eq(6000 + RelativePositioning::IDEAL_DISTANCE) end it 'positions issue in the middle of other two if distance is not big enough' do @@ -138,5 +166,39 @@ describe Issue, 'RelativePositioning' do expect(new_issue.relative_position).to eq(250) end + + it 'positions issue in the middle of other two is there is no place' do + issue.update relative_position: 100 + issue1.update relative_position: 101 + + new_issue.move_between(issue, issue1) + + expect(new_issue.relative_position).to be_between(issue.relative_position, issue1.relative_position) + end + + it 'uses rebalancing if there is no place' do + issue.update relative_position: 100 + issue1.update relative_position: 101 + issue2 = create(:issue, relative_position: 102, project: project) + new_issue.update relative_position: 103 + + new_issue.move_between(issue1, issue2) + new_issue.save! + + expect(new_issue.relative_position).to be_between(issue1.relative_position, issue2.relative_position) + expect(issue.reload.relative_position).not_to eq(100) + end + + it 'positions issue right if we pass none-sequential parameters' do + issue.update relative_position: 99 + issue1.update relative_position: 101 + issue2 = create(:issue, relative_position: 102, project: project) + new_issue.update relative_position: 103 + + new_issue.move_between(issue, issue2) + new_issue.save! + + expect(new_issue.relative_position).to be(100) + end end end -- cgit v1.2.1 From af8cc2e064bb97a8a1801521735d5403b189bfb5 Mon Sep 17 00:00:00 2001 From: Lin Jen-Shin Date: Tue, 14 Mar 2017 20:13:36 +0800 Subject: Use `remove_foreign_key :timelogs, name: '...'` Feedback: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/9908#note_25324225 --- db/migrate/20170124174637_add_foreign_keys_to_timelogs.rb | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/db/migrate/20170124174637_add_foreign_keys_to_timelogs.rb b/db/migrate/20170124174637_add_foreign_keys_to_timelogs.rb index 676e18cddd3..a7d4e141a1a 100644 --- a/db/migrate/20170124174637_add_foreign_keys_to_timelogs.rb +++ b/db/migrate/20170124174637_add_foreign_keys_to_timelogs.rb @@ -49,15 +49,8 @@ class AddForeignKeysToTimelogs < ActiveRecord::Migration Timelog.where('issue_id IS NOT NULL').update_all("trackable_id = issue_id, trackable_type = 'Issue'") Timelog.where('merge_request_id IS NOT NULL').update_all("trackable_id = merge_request_id, trackable_type = 'MergeRequest'") - constraint = - if Gitlab::Database.postgresql? - 'CONSTRAINT' - else - 'FOREIGN KEY' - end - - execute "ALTER TABLE timelogs DROP #{constraint} fk_timelogs_issues_issue_id" - execute "ALTER TABLE timelogs DROP #{constraint} fk_timelogs_merge_requests_merge_request_id" + remove_foreign_key :timelogs, name: 'fk_timelogs_issues_issue_id' + remove_foreign_key :timelogs, name: 'fk_timelogs_merge_requests_merge_request_id' remove_columns :timelogs, :issue_id, :merge_request_id end -- cgit v1.2.1 From 82f61410b65c6044aa630d5f5b27fada207b870e Mon Sep 17 00:00:00 2001 From: Chris Peressini Date: Tue, 14 Mar 2017 12:23:44 +0000 Subject: Change Canccel button class Used to be `btn-default`, change it to `btn-cancel` --- app/views/admin/applications/_form.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/admin/applications/_form.html.haml b/app/views/admin/applications/_form.html.haml index c689b26d6e6..061f8991b11 100644 --- a/app/views/admin/applications/_form.html.haml +++ b/app/views/admin/applications/_form.html.haml @@ -26,4 +26,4 @@ .form-actions = f.submit 'Submit', class: "btn btn-save wide" - = link_to "Cancel", admin_applications_path, class: "btn btn-default" + = link_to "Cancel", admin_applications_path, class: "btn btn-cancel" -- cgit v1.2.1 From 83e36064998f77f40c534bad531b6cea19ec198b Mon Sep 17 00:00:00 2001 From: Lin Jen-Shin Date: Tue, 14 Mar 2017 21:40:58 +0800 Subject: Split to two commands, feedback: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/9908#note_25331127 --- .gitlab-ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index b1ca61604d5..406a0f3dcad 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -227,7 +227,8 @@ rake db:rollback: <<: *use-db <<: *dedicated-runner script: - - bundle exec rake db:rollback db:migrate STEP=120 + - bundle exec rake db:rollback STEP=120 + - bundle exec rake db:migrate rake db:seed_fu: stage: test -- cgit v1.2.1 From 84561349ffa7aa079f5bd371ba51bef02ee8f6df Mon Sep 17 00:00:00 2001 From: Adam Niedzielski Date: Tue, 14 Mar 2017 14:45:38 +0100 Subject: Describe polling with ETag caching --- CONTRIBUTING.md | 5 +++-- doc/development/polling.md | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 doc/development/polling.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9a6e3feec4c..ae143c58290 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -403,8 +403,8 @@ There are a few rules to get your merge request accepted: - Avoid repeated polling of endpoints that require a significant amount of overhead - Check for N+1 queries via the SQL log or [`QueryRecorder`](https://docs.gitlab.com/ce/development/merge_request_performance_guidelines.html) - Avoid repeated access of filesystem -1. If you need polling to support real-time features, consider using this [described long - polling approach](https://gitlab.com/gitlab-org/gitlab-ce/issues/26926). +1. If you need polling to support real-time features, please use + [polling with ETag caching][polling-etag]. 1. Changes after submitting the merge request should be in separate commits (no squashing). If necessary, you will be asked to squash when the review is over, before merging. @@ -547,6 +547,7 @@ available at [http://contributor-covenant.org/version/1/1/0/](http://contributor [UX Guide for GitLab]: http://docs.gitlab.com/ce/development/ux_guide/ [license-finder-doc]: doc/development/licensing.md [GitLab Inc engineering workflow]: https://about.gitlab.com/handbook/engineering/workflow/#labelling-issues +[polling-etag]: https://docs.gitlab.com/ce/development/polling.html [^1]: Specs other than JavaScript specs are considered backend code. Haml changes are considered backend code if they include Ruby code other than just diff --git a/doc/development/polling.md b/doc/development/polling.md new file mode 100644 index 00000000000..a086aca6697 --- /dev/null +++ b/doc/development/polling.md @@ -0,0 +1,41 @@ +# Polling with ETag caching + +Polling for changes (repeatedly asking server if there are any new changes) +introduces high load on a GitLab instance, because it usually requires +executing at least a few SQL queries. This makes scaling large GitLab +instances (like GitLab.com) very difficult so we do not allow adding new +features that require polling and hit the database. + +Instead you should use polling mechanism with ETag caching in Redis. + +## How to use it + +1. Add the path of the endpoint which you want to poll to + `Gitlab::EtagCaching::Middleware`. +1. Implement cache invalidation for the path of your endpoint using + `Gitlab::EtagCaching::Store`. Whenever a resource changes you + have to invalidate the ETag for the path that depends on this + resource. +1. Check that the mechanism works: + - requests should return status code 304 + - there should be no SQL queries logged in `log/development.log` + +## How it works + +1. Whenever a resource changes we generate a random value and store it in + Redis. +1. When a client makes a request we set the `ETag` response header to the value + from Redis. +1. The client caches the response (client-side caching) and sends the ETag as + the `If-None-Modified` header with every subsequent request for the same + resource. +1. If the `If-None-Modified` header matches the current value in Redis we know + that the resource did not change so we can send 304 response immediately, + without querying the database at all. The client's browser will use the + cached response. +1. If the `If-None-Modified` header does not match the current value in Redis + we have to generate a new response, because the resource changed. + +For more information see: +- [RFC 7232](https://tools.ietf.org/html/rfc7232) +- [ETag proposal](https://gitlab.com/gitlab-org/gitlab-ce/issues/26926) -- cgit v1.2.1 From 1cba12d35f0e2bad8b2903a58c883437a701fee4 Mon Sep 17 00:00:00 2001 From: szymon Date: Tue, 14 Mar 2017 14:03:28 +0000 Subject: Update using_docker_images.md --- doc/ci/docker/using_docker_images.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/doc/ci/docker/using_docker_images.md b/doc/ci/docker/using_docker_images.md index 00787323b6b..f025a7e3496 100644 --- a/doc/ci/docker/using_docker_images.md +++ b/doc/ci/docker/using_docker_images.md @@ -170,13 +170,17 @@ services: ``` When the job is run, `tutum/wordpress` will be started and you will have -access to it from your build container under the hostname `tutum__wordpress`. +access to it from your build container under the hostnames `tutum-wordpress` +(requires GitLab Runner v1.1.0 or newer) and `tutum__wordpress`. -The alias hostname for the service is made from the image name following these +*Note: hostname with underscores is not RFC valid and may cause problems in 3rd party applications.* + +The alias hostnames for the service are made from the image name following these rules: 1. Everything after `:` is stripped -2. Slash (`/`) is replaced with double underscores (`__`) +2. Slash (`/`) is replaced with double underscores (`__`) - primary alias +3. Slash (`/`) is replaced with dash (`-`) - secondary alias, requires GitLab Runner v1.1.0 or newer ## Configuring services -- cgit v1.2.1 From 283789c50913ec95725e83b4d270151347d205cc Mon Sep 17 00:00:00 2001 From: Nick Thomas Date: Tue, 14 Mar 2017 12:56:07 +0000 Subject: Fix intermittent spec failures in notify_spec.rb --- spec/mailers/notify_spec.rb | 231 +++++++++++++++++++++++--------------------- 1 file changed, 120 insertions(+), 111 deletions(-) diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb index b692142713f..e822d7eb348 100644 --- a/spec/mailers/notify_spec.rb +++ b/spec/mailers/notify_spec.rb @@ -8,6 +8,15 @@ describe Notify do include_context 'gitlab email notification' + def have_referable_subject(referable, reply: false) + prefix = referable.project.name if referable.project + prefix = "Re: #{prefix}" if reply + + suffix = "#{referable.title} (#{referable.to_reference})" + + have_subject [prefix, suffix].compact.join(' | ') + end + context 'for a project' do describe 'items that are assignable, the email' do let(:current_user) { create(:user, email: "current@email.com") } @@ -41,11 +50,11 @@ describe Notify do it_behaves_like 'an unsubscribeable thread' it 'has the correct subject' do - is_expected.to have_subject /#{project.name} \| #{issue.title} \(##{issue.iid}\)/ + is_expected.to have_referable_subject(issue) end it 'contains a link to the new issue' do - is_expected.to have_body_text /#{namespace_project_issue_path project.namespace, project, issue}/ + is_expected.to have_body_text namespace_project_issue_path(project.namespace, project, issue) end context 'when enabled email_author_in_body' do @@ -55,7 +64,7 @@ describe Notify do it 'contains a link to note author' do is_expected.to have_body_text issue.author_name - is_expected.to have_body_text /wrote\:/ + is_expected.to have_body_text 'wrote:' end end end @@ -66,7 +75,7 @@ describe Notify do it_behaves_like 'it should show Gmail Actions View Issue link' it 'contains the description' do - is_expected.to have_body_text /#{issue_with_description.description}/ + is_expected.to have_body_text issue_with_description.description end end @@ -87,19 +96,19 @@ describe Notify do end it 'has the correct subject' do - is_expected.to have_subject /#{issue.title} \(##{issue.iid}\)/ + is_expected.to have_referable_subject(issue, reply: true) end it 'contains the name of the previous assignee' do - is_expected.to have_body_text /#{previous_assignee.name}/ + is_expected.to have_body_text previous_assignee.name end it 'contains the name of the new assignee' do - is_expected.to have_body_text /#{assignee.name}/ + is_expected.to have_body_text assignee.name end it 'contains a link to the issue' do - is_expected.to have_body_text /#{namespace_project_issue_path project.namespace, project, issue}/ + is_expected.to have_body_text namespace_project_issue_path(project.namespace, project, issue) end end @@ -121,15 +130,15 @@ describe Notify do end it 'has the correct subject' do - is_expected.to have_subject /#{issue.title} \(##{issue.iid}\)/ + is_expected.to have_referable_subject(issue, reply: true) end it 'contains the names of the added labels' do - is_expected.to have_body_text /foo, bar, and baz/ + is_expected.to have_body_text 'foo, bar, and baz' end it 'contains a link to the issue' do - is_expected.to have_body_text /#{namespace_project_issue_path project.namespace, project, issue}/ + is_expected.to have_body_text namespace_project_issue_path(project.namespace, project, issue) end end @@ -150,19 +159,19 @@ describe Notify do end it 'has the correct subject' do - is_expected.to have_subject /#{issue.title} \(##{issue.iid}\)/i + is_expected.to have_referable_subject(issue, reply: true) end it 'contains the new status' do - is_expected.to have_body_text /#{status}/i + is_expected.to have_body_text status end it 'contains the user name' do - is_expected.to have_body_text /#{current_user.name}/i + is_expected.to have_body_text current_user.name end it 'contains a link to the issue' do - is_expected.to have_body_text /#{namespace_project_issue_path project.namespace, project, issue}/ + is_expected.to have_body_text(namespace_project_issue_path project.namespace, project, issue) end end @@ -181,7 +190,7 @@ describe Notify do end it 'has the correct subject' do - is_expected.to have_subject /#{issue.title} \(##{issue.iid}\)/i + is_expected.to have_referable_subject(issue, reply: true) end it 'contains link to new issue' do @@ -191,7 +200,7 @@ describe Notify do end it 'contains a link to the original issue' do - is_expected.to have_body_text /#{namespace_project_issue_path project.namespace, project, issue}/ + is_expected.to have_body_text namespace_project_issue_path(project.namespace, project, issue) end end end @@ -212,19 +221,19 @@ describe Notify do it_behaves_like 'an unsubscribeable thread' it 'has the correct subject' do - is_expected.to have_subject /#{merge_request.title} \(#{merge_request.to_reference}\)/ + is_expected.to have_referable_subject(merge_request) end it 'contains a link to the new merge request' do - is_expected.to have_body_text /#{namespace_project_merge_request_path(project.namespace, project, merge_request)}/ + is_expected.to have_body_text namespace_project_merge_request_path(project.namespace, project, merge_request) end it 'contains the source branch for the merge request' do - is_expected.to have_body_text /#{merge_request.source_branch}/ + is_expected.to have_body_text merge_request.source_branch end it 'contains the target branch for the merge request' do - is_expected.to have_body_text /#{merge_request.target_branch}/ + is_expected.to have_body_text merge_request.target_branch end context 'when enabled email_author_in_body' do @@ -234,7 +243,7 @@ describe Notify do it 'contains a link to note author' do is_expected.to have_body_text merge_request.author_name - is_expected.to have_body_text /wrote\:/ + is_expected.to have_body_text 'wrote:' end end end @@ -246,7 +255,7 @@ describe Notify do it_behaves_like "an unsubscribeable thread" it 'contains the description' do - is_expected.to have_body_text /#{merge_request_with_description.description}/ + is_expected.to have_body_text merge_request_with_description.description end end @@ -267,19 +276,19 @@ describe Notify do end it 'has the correct subject' do - is_expected.to have_subject /#{merge_request.title} \(#{merge_request.to_reference}\)/ + is_expected.to have_referable_subject(merge_request, reply: true) end it 'contains the name of the previous assignee' do - is_expected.to have_body_text /#{previous_assignee.name}/ + is_expected.to have_body_text previous_assignee.name end it 'contains the name of the new assignee' do - is_expected.to have_body_text /#{assignee.name}/ + is_expected.to have_body_text assignee.name end it 'contains a link to the merge request' do - is_expected.to have_body_text /#{namespace_project_merge_request_path project.namespace, project, merge_request}/ + is_expected.to have_body_text namespace_project_merge_request_path(project.namespace, project, merge_request) end end @@ -301,15 +310,15 @@ describe Notify do end it 'has the correct subject' do - is_expected.to have_subject /#{merge_request.title} \(#{merge_request.to_reference}\)/ + is_expected.to have_referable_subject(merge_request, reply: true) end it 'contains the names of the added labels' do - is_expected.to have_body_text /foo, bar, and baz/ + is_expected.to have_body_text 'foo, bar, and baz' end it 'contains a link to the merge request' do - is_expected.to have_body_text /#{namespace_project_merge_request_path project.namespace, project, merge_request}/ + is_expected.to have_body_text namespace_project_merge_request_path(project.namespace, project, merge_request) end end @@ -330,19 +339,19 @@ describe Notify do end it 'has the correct subject' do - is_expected.to have_subject /#{merge_request.title} \(#{merge_request.to_reference}\)/i + is_expected.to have_referable_subject(merge_request, reply: true) end it 'contains the new status' do - is_expected.to have_body_text /#{status}/i + is_expected.to have_body_text status end it 'contains the user name' do - is_expected.to have_body_text /#{current_user.name}/i + is_expected.to have_body_text current_user.name end it 'contains a link to the merge request' do - is_expected.to have_body_text /#{namespace_project_merge_request_path project.namespace, project, merge_request}/ + is_expected.to have_body_text namespace_project_merge_request_path(project.namespace, project, merge_request) end end @@ -363,15 +372,15 @@ describe Notify do end it 'has the correct subject' do - is_expected.to have_subject /#{merge_request.title} \(#{merge_request.to_reference}\)/ + is_expected.to have_referable_subject(merge_request, reply: true) end it 'contains the new status' do - is_expected.to have_body_text /merged/i + is_expected.to have_body_text 'merged' end it 'contains a link to the merge request' do - is_expected.to have_body_text /#{namespace_project_merge_request_path project.namespace, project, merge_request}/ + is_expected.to have_body_text namespace_project_merge_request_path(project.namespace, project, merge_request) end end end @@ -387,15 +396,15 @@ describe Notify do it_behaves_like "a user cannot unsubscribe through footer link" it 'has the correct subject' do - is_expected.to have_subject /Project was moved/ + is_expected.to have_subject "#{project.name} | Project was moved" end it 'contains name of project' do - is_expected.to have_body_text /#{project.name_with_namespace}/ + is_expected.to have_body_text project.name_with_namespace end it 'contains new user role' do - is_expected.to have_body_text /#{project.ssh_url_to_repo}/ + is_expected.to have_body_text project.ssh_url_to_repo end end @@ -424,9 +433,9 @@ describe Notify do expect(to_emails[0].address).to eq(project.members.owners_and_masters.first.user.notification_email) is_expected.to have_subject "Request to join the #{project.name_with_namespace} project" - is_expected.to have_body_text /#{project.name_with_namespace}/ - is_expected.to have_body_text /#{namespace_project_project_members_url(project.namespace, project)}/ - is_expected.to have_body_text /#{project_member.human_access}/ + is_expected.to have_body_text project.name_with_namespace + is_expected.to have_body_text namespace_project_project_members_url(project.namespace, project) + is_expected.to have_body_text project_member.human_access end end @@ -451,9 +460,9 @@ describe Notify do expect(to_emails[0].address).to eq(group.members.owners_and_masters.first.user.notification_email) is_expected.to have_subject "Request to join the #{project.name_with_namespace} project" - is_expected.to have_body_text /#{project.name_with_namespace}/ - is_expected.to have_body_text /#{namespace_project_project_members_url(project.namespace, project)}/ - is_expected.to have_body_text /#{project_member.human_access}/ + is_expected.to have_body_text project.name_with_namespace + is_expected.to have_body_text namespace_project_project_members_url(project.namespace, project) + is_expected.to have_body_text project_member.human_access end end end @@ -473,8 +482,8 @@ describe Notify do it 'contains all the useful information' do is_expected.to have_subject "Access to the #{project.name_with_namespace} project was denied" - is_expected.to have_body_text /#{project.name_with_namespace}/ - is_expected.to have_body_text /#{project.web_url}/ + is_expected.to have_body_text project.name_with_namespace + is_expected.to have_body_text project.web_url end end @@ -490,9 +499,9 @@ describe Notify do it 'contains all the useful information' do is_expected.to have_subject "Access to the #{project.name_with_namespace} project was granted" - is_expected.to have_body_text /#{project.name_with_namespace}/ - is_expected.to have_body_text /#{project.web_url}/ - is_expected.to have_body_text /#{project_member.human_access}/ + is_expected.to have_body_text project.name_with_namespace + is_expected.to have_body_text project.web_url + is_expected.to have_body_text project_member.human_access end end @@ -521,10 +530,10 @@ describe Notify do it 'contains all the useful information' do is_expected.to have_subject "Invitation to join the #{project.name_with_namespace} project" - is_expected.to have_body_text /#{project.name_with_namespace}/ - is_expected.to have_body_text /#{project.web_url}/ - is_expected.to have_body_text /#{project_member.human_access}/ - is_expected.to have_body_text /#{project_member.invite_token}/ + is_expected.to have_body_text project.name_with_namespace + is_expected.to have_body_text project.web_url + is_expected.to have_body_text project_member.human_access + is_expected.to have_body_text project_member.invite_token end end @@ -546,10 +555,10 @@ describe Notify do it 'contains all the useful information' do is_expected.to have_subject 'Invitation accepted' - is_expected.to have_body_text /#{project.name_with_namespace}/ - is_expected.to have_body_text /#{project.web_url}/ - is_expected.to have_body_text /#{project_member.invite_email}/ - is_expected.to have_body_text /#{invited_user.name}/ + is_expected.to have_body_text project.name_with_namespace + is_expected.to have_body_text project.web_url + is_expected.to have_body_text project_member.invite_email + is_expected.to have_body_text invited_user.name end end @@ -570,9 +579,9 @@ describe Notify do it 'contains all the useful information' do is_expected.to have_subject 'Invitation declined' - is_expected.to have_body_text /#{project.name_with_namespace}/ - is_expected.to have_body_text /#{project.web_url}/ - is_expected.to have_body_text /#{project_member.invite_email}/ + is_expected.to have_body_text project.name_with_namespace + is_expected.to have_body_text project.web_url + is_expected.to have_body_text project_member.invite_email end end @@ -598,11 +607,11 @@ describe Notify do end it 'contains the message from the note' do - is_expected.to have_body_text /#{note.note}/ + is_expected.to have_body_text note.note end it 'does not contain note author' do - is_expected.not_to have_body_text /wrote\:/ + is_expected.not_to have_body_text 'wrote:' end context 'when enabled email_author_in_body' do @@ -612,7 +621,7 @@ describe Notify do it 'contains a link to note author' do is_expected.to have_body_text note.author_name - is_expected.to have_body_text /wrote\:/ + is_expected.to have_body_text 'wrote:' end end end @@ -632,7 +641,7 @@ describe Notify do it_behaves_like 'a user cannot unsubscribe through footer link' it 'has the correct subject' do - is_expected.to have_subject /Re: #{project.name} | #{commit.title} \(#{commit.short_id}\)/ + is_expected.to have_subject "Re: #{project.name} | #{commit.title.strip} (#{commit.short_id})" end it 'contains a link to the commit' do @@ -655,11 +664,11 @@ describe Notify do it_behaves_like 'an unsubscribeable thread' it 'has the correct subject' do - is_expected.to have_subject /#{merge_request.title} \(#{merge_request.to_reference}\)/ + is_expected.to have_referable_subject(merge_request, reply: true) end it 'contains a link to the merge request note' do - is_expected.to have_body_text /#{note_on_merge_request_path}/ + is_expected.to have_body_text note_on_merge_request_path end end @@ -678,11 +687,11 @@ describe Notify do it_behaves_like 'an unsubscribeable thread' it 'has the correct subject' do - is_expected.to have_subject /#{issue.title} \(##{issue.iid}\)/ + is_expected.to have_referable_subject(issue, reply: true) end it 'contains a link to the issue note' do - is_expected.to have_body_text /#{note_on_issue_path}/ + is_expected.to have_body_text note_on_issue_path end end end @@ -698,11 +707,11 @@ describe Notify do let(:note) { create(model, project: project, author: note_author) } it "includes diffs with character-level highlighting" do - is_expected.to have_body_text /}<\/span><\/span>/ + is_expected.to have_body_text '}' end it 'contains a link to the diff file' do - is_expected.to have_body_text /#{note.diff_file.file_path}/ + is_expected.to have_body_text note.diff_file.file_path end it_behaves_like 'it should have Gmail Actions links' @@ -718,11 +727,11 @@ describe Notify do end it 'contains the message from the note' do - is_expected.to have_body_text /#{note.note}/ + is_expected.to have_body_text note.note end it 'does not contain note author' do - is_expected.not_to have_body_text /wrote\:/ + is_expected.not_to have_body_text 'wrote:' end context 'when enabled email_author_in_body' do @@ -732,7 +741,7 @@ describe Notify do it 'contains a link to note author' do is_expected.to have_body_text note.author_name - is_expected.to have_body_text /wrote\:/ + is_expected.to have_body_text 'wrote:' end end end @@ -777,9 +786,9 @@ describe Notify do it 'contains all the useful information' do is_expected.to have_subject "Request to join the #{group.name} group" - is_expected.to have_body_text /#{group.name}/ - is_expected.to have_body_text /#{group_group_members_url(group)}/ - is_expected.to have_body_text /#{group_member.human_access}/ + is_expected.to have_body_text group.name + is_expected.to have_body_text group_group_members_url(group) + is_expected.to have_body_text group_member.human_access end end @@ -798,8 +807,8 @@ describe Notify do it 'contains all the useful information' do is_expected.to have_subject "Access to the #{group.name} group was denied" - is_expected.to have_body_text /#{group.name}/ - is_expected.to have_body_text /#{group.web_url}/ + is_expected.to have_body_text group.name + is_expected.to have_body_text group.web_url end end @@ -816,9 +825,9 @@ describe Notify do it 'contains all the useful information' do is_expected.to have_subject "Access to the #{group.name} group was granted" - is_expected.to have_body_text /#{group.name}/ - is_expected.to have_body_text /#{group.web_url}/ - is_expected.to have_body_text /#{group_member.human_access}/ + is_expected.to have_body_text group.name + is_expected.to have_body_text group.web_url + is_expected.to have_body_text group_member.human_access end end @@ -847,10 +856,10 @@ describe Notify do it 'contains all the useful information' do is_expected.to have_subject "Invitation to join the #{group.name} group" - is_expected.to have_body_text /#{group.name}/ - is_expected.to have_body_text /#{group.web_url}/ - is_expected.to have_body_text /#{group_member.human_access}/ - is_expected.to have_body_text /#{group_member.invite_token}/ + is_expected.to have_body_text group.name + is_expected.to have_body_text group.web_url + is_expected.to have_body_text group_member.human_access + is_expected.to have_body_text group_member.invite_token end end @@ -872,10 +881,10 @@ describe Notify do it 'contains all the useful information' do is_expected.to have_subject 'Invitation accepted' - is_expected.to have_body_text /#{group.name}/ - is_expected.to have_body_text /#{group.web_url}/ - is_expected.to have_body_text /#{group_member.invite_email}/ - is_expected.to have_body_text /#{invited_user.name}/ + is_expected.to have_body_text group.name + is_expected.to have_body_text group.web_url + is_expected.to have_body_text group_member.invite_email + is_expected.to have_body_text invited_user.name end end @@ -896,9 +905,9 @@ describe Notify do it 'contains all the useful information' do is_expected.to have_subject 'Invitation declined' - is_expected.to have_body_text /#{group.name}/ - is_expected.to have_body_text /#{group.web_url}/ - is_expected.to have_body_text /#{group_member.invite_email}/ + is_expected.to have_body_text group.name + is_expected.to have_body_text group.web_url + is_expected.to have_body_text group_member.invite_email end end end @@ -925,11 +934,11 @@ describe Notify do end it 'has the correct subject' do - is_expected.to have_subject /^Confirmation instructions/ + is_expected.to have_subject 'Confirmation instructions | A Nice Suffix' end it 'includes a link to the site' do - is_expected.to have_body_text /#{example_site_path}/ + is_expected.to have_body_text example_site_path end end @@ -952,11 +961,11 @@ describe Notify do end it 'has the correct subject' do - is_expected.to have_subject /Pushed new branch master/ + is_expected.to have_subject "[Git][#{project.full_path}] Pushed new branch master" end it 'contains a link to the branch' do - is_expected.to have_body_text /#{tree_path}/ + is_expected.to have_body_text tree_path end end @@ -979,11 +988,11 @@ describe Notify do end it 'has the correct subject' do - is_expected.to have_subject /Pushed new tag v1\.0/ + is_expected.to have_subject "[Git][#{project.full_path}] Pushed new tag v1.0" end it 'contains a link to the tag' do - is_expected.to have_body_text /#{tree_path}/ + is_expected.to have_body_text tree_path end end @@ -1005,7 +1014,7 @@ describe Notify do end it 'has the correct subject' do - is_expected.to have_subject /Deleted branch master/ + is_expected.to have_subject "[Git][#{project.full_path}] Deleted branch master" end end @@ -1027,7 +1036,7 @@ describe Notify do end it 'has the correct subject' do - is_expected.to have_subject /Deleted tag v1\.0/ + is_expected.to have_subject "[Git][#{project.full_path}] Deleted tag v1.0" end end @@ -1055,23 +1064,23 @@ describe Notify do end it 'has the correct subject' do - is_expected.to have_subject /\[#{project.path_with_namespace}\]\[master\] #{commits.length} commits:/ + is_expected.to have_subject "[Git][#{project.full_path}][master] #{commits.length} commits: Ruby files modified" end it 'includes commits list' do - is_expected.to have_body_text /Change some files/ + is_expected.to have_body_text 'Change some files' end it 'includes diffs with character-level highlighting' do - is_expected.to have_body_text /def<\/span> archive_formats_regex/ + is_expected.to have_body_text 'def archive_formats_regex' end it 'contains a link to the diff' do - is_expected.to have_body_text /#{diff_path}/ + is_expected.to have_body_text diff_path end it 'does not contain the misleading footer' do - is_expected.not_to have_body_text /you are a member of/ + is_expected.not_to have_body_text 'you are a member of' end context "when set to send from committer email if domain matches" do @@ -1157,19 +1166,19 @@ describe Notify do end it 'has the correct subject' do - is_expected.to have_subject /#{commits.first.title}/ + is_expected.to have_subject "[Git][#{project.full_path}][master] #{commits.first.title}" end it 'includes commits list' do - is_expected.to have_body_text /Change some files/ + is_expected.to have_body_text 'Change some files' end it 'includes diffs with character-level highlighting' do - is_expected.to have_body_text /def<\/span> archive_formats_regex/ + is_expected.to have_body_text 'def archive_formats_regex' end it 'contains a link to the diff' do - is_expected.to have_body_text /#{diff_path}/ + is_expected.to have_body_text diff_path end end -- cgit v1.2.1 From 29e34c332687be9456578a9b5f60adb10f4e10b5 Mon Sep 17 00:00:00 2001 From: Valery Sizov Date: Tue, 7 Mar 2017 18:04:44 +0200 Subject: Preserve order by priority on issues board --- app/models/issue.rb | 7 +++++++ app/services/boards/issues/list_service.rb | 2 +- spec/models/issue_spec.rb | 15 ++++++++++++++ spec/services/boards/issues/list_service_spec.rb | 26 ++++++++++++++++++++++++ 4 files changed, 49 insertions(+), 1 deletion(-) diff --git a/app/models/issue.rb b/app/models/issue.rb index 0f7a26ee3e1..dba9398a43c 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -96,6 +96,13 @@ class Issue < ActiveRecord::Base end end + def self.order_by_position_and_priority + order_labels_priority. + reorder(Gitlab::Database.nulls_last_order('relative_position', 'ASC'), + Gitlab::Database.nulls_last_order('highest_priority', 'ASC'), + "id DESC") + end + # `from` argument can be a Namespace or Project. def to_reference(from = nil, full: false) reference = "#{self.class.reference_prefix}#{iid}" diff --git a/app/services/boards/issues/list_service.rb b/app/services/boards/issues/list_service.rb index 185838764c1..83f51947bd4 100644 --- a/app/services/boards/issues/list_service.rb +++ b/app/services/boards/issues/list_service.rb @@ -5,7 +5,7 @@ module Boards issues = IssuesFinder.new(current_user, filter_params).execute issues = without_board_labels(issues) unless movable_list? issues = with_list_label(issues) if movable_list? - issues.reorder(Gitlab::Database.nulls_last_order('relative_position', 'ASC')) + issues.order_by_position_and_priority end private diff --git a/spec/models/issue_spec.rb b/spec/models/issue_spec.rb index bba9058f394..f67fbe79bde 100644 --- a/spec/models/issue_spec.rb +++ b/spec/models/issue_spec.rb @@ -22,6 +22,21 @@ describe Issue, models: true do it { is_expected.to have_db_index(:deleted_at) } end + describe '#order_by_position_and_priority' do + let(:project) { create :empty_project } + let(:p1) { create(:label, title: 'P1', project: project, priority: 1) } + let(:p2) { create(:label, title: 'P2', project: project, priority: 2) } + let!(:issue1) { create(:labeled_issue, project: project, labels: [p1]) } + let!(:issue2) { create(:labeled_issue, project: project, labels: [p2]) } + let!(:issue3) { create(:issue, project: project, relative_position: 100) } + let!(:issue4) { create(:issue, project: project, relative_position: 200) } + + it 'returns ordered list' do + expect(project.issues.order_by_position_and_priority). + to match [issue3, issue4, issue1, issue2] + end + end + describe '#to_reference' do let(:namespace) { build(:namespace, path: 'sample-namespace') } let(:project) { build(:empty_project, name: 'sample-project', namespace: namespace) } diff --git a/spec/services/boards/issues/list_service_spec.rb b/spec/services/boards/issues/list_service_spec.rb index 01baedc4761..22115c6566d 100644 --- a/spec/services/boards/issues/list_service_spec.rb +++ b/spec/services/boards/issues/list_service_spec.rb @@ -43,6 +43,32 @@ describe Boards::Issues::ListService, services: true do described_class.new(project, user, params).execute end + context 'issues are ordered by priority' do + it 'returns opened issues when list_id is missing' do + params = { board_id: board.id } + + issues = described_class.new(project, user, params).execute + + expect(issues).to eq [opened_issue2, reopened_issue1, opened_issue1] + end + + it 'returns closed issues when listing issues from Done' do + params = { board_id: board.id, id: done.id } + + issues = described_class.new(project, user, params).execute + + expect(issues).to eq [closed_issue4, closed_issue2, closed_issue3, closed_issue1] + end + + it 'returns opened issues that have label list applied when listing issues from a label list' do + params = { board_id: board.id, id: list1.id } + + issues = described_class.new(project, user, params).execute + + expect(issues).to eq [list1_issue3, list1_issue1, list1_issue2] + end + end + context 'with list that does not belong to the board' do it 'raises an error' do list = create(:list) -- cgit v1.2.1 From 3e29936a15eb2f6c9bc2d92ebf1a67a0cadb916e Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Tue, 14 Mar 2017 01:24:56 -0500 Subject: Fix first line markdown helper for user profile activity stream Fix https://gitlab.com/gitlab-org/gitlab-ce/issues/29425 --- app/helpers/events_helper.rb | 4 ++-- app/helpers/gitlab_markdown_helper.rb | 2 +- spec/helpers/gitlab_markdown_helper_spec.rb | 20 ++++++++++++++++++-- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb index 5605393c0c3..fb872a13f74 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -165,8 +165,8 @@ module EventsHelper sanitize( text, - tags: %w(a img b pre code p span), - attributes: Rails::Html::WhiteListSanitizer.allowed_attributes + ['style'] + tags: %w(a img gl-emoji b pre code p span), + attributes: Rails::Html::WhiteListSanitizer.allowed_attributes + ['style', 'data-name', 'data-unicode-version'] ) end diff --git a/app/helpers/gitlab_markdown_helper.rb b/app/helpers/gitlab_markdown_helper.rb index 6d365ea9251..6226cfe25cf 100644 --- a/app/helpers/gitlab_markdown_helper.rb +++ b/app/helpers/gitlab_markdown_helper.rb @@ -172,7 +172,7 @@ module GitlabMarkdownHelper # text hasn't already been truncated, then append "..." to the node contents # and return true. Otherwise return false. def truncate_if_block(node, truncated) - if node.element? && node.description.block? && !truncated + if node.element? && node.description&.block? && !truncated node.inner_html = "#{node.inner_html}..." if node.next_sibling true else diff --git a/spec/helpers/gitlab_markdown_helper_spec.rb b/spec/helpers/gitlab_markdown_helper_spec.rb index 9ffd4b9371c..6cf3f86680a 100644 --- a/spec/helpers/gitlab_markdown_helper_spec.rb +++ b/spec/helpers/gitlab_markdown_helper_spec.rb @@ -152,9 +152,8 @@ describe GitlabMarkdownHelper do end describe '#first_line_in_markdown' do - let(:text) { "@#{user.username}, can you look at this?\nHello world\n"} - it 'truncates Markdown properly' do + text = "@#{user.username}, can you look at this?\nHello world\n" actual = first_line_in_markdown(text, 100, project: project) doc = Nokogiri::HTML.parse(actual) @@ -169,6 +168,23 @@ describe GitlabMarkdownHelper do expect(doc.content).to eq "@#{user.username}, can you look at this?..." end + + it 'truncates Markdown with emoji properly' do + text = "foo :wink:\nbar :grinning:" + actual = first_line_in_markdown(text, 100, project: project) + + doc = Nokogiri::HTML.parse(actual) + + # Make sure we didn't create invalid markup + # But also account for the 2 errors caused by the unknown `gl-emoji` elements + expect(doc.errors.length).to eq(2) + + expect(doc.css('gl-emoji').length).to eq(2) + expect(doc.css('gl-emoji')[0].attr('data-name')).to eq 'wink' + expect(doc.css('gl-emoji')[1].attr('data-name')).to eq 'grinning' + + expect(doc.content).to eq "foo 😉\nbar 😀" + end end describe '#cross_project_reference' do -- cgit v1.2.1 From 8daa641e4d6d5dd2fab78abd851b0e8899d4ea11 Mon Sep 17 00:00:00 2001 From: Raveesh Date: Tue, 14 Mar 2017 12:50:32 -0400 Subject: Switch to using milestone.to_reference when displaying milestone Fix #29214 --- app/views/projects/milestones/edit.html.haml | 2 +- app/views/projects/milestones/show.html.haml | 2 +- changelogs/unreleased/fix-milestone-name-on-show.yml | 4 ++++ 3 files changed, 6 insertions(+), 2 deletions(-) create mode 100644 changelogs/unreleased/fix-milestone-name-on-show.yml diff --git a/app/views/projects/milestones/edit.html.haml b/app/views/projects/milestones/edit.html.haml index 11f41e75e63..55b0b837c6d 100644 --- a/app/views/projects/milestones/edit.html.haml +++ b/app/views/projects/milestones/edit.html.haml @@ -5,7 +5,7 @@ %div{ class: container_class } %h3.page-title - Edit Milestone ##{@milestone.iid} + Edit Milestone #{@milestone.to_reference} %hr diff --git a/app/views/projects/milestones/show.html.haml b/app/views/projects/milestones/show.html.haml index b4dde2c86c9..d16f49bd33a 100644 --- a/app/views/projects/milestones/show.html.haml +++ b/app/views/projects/milestones/show.html.haml @@ -20,7 +20,7 @@ .header-text-content %span.identifier %strong - Milestone %#{@milestone.iid} + Milestone #{@milestone.to_reference} - if @milestone.due_date || @milestone.start_date = milestone_date_range(@milestone) .milestone-buttons diff --git a/changelogs/unreleased/fix-milestone-name-on-show.yml b/changelogs/unreleased/fix-milestone-name-on-show.yml new file mode 100644 index 00000000000..bf17a758c80 --- /dev/null +++ b/changelogs/unreleased/fix-milestone-name-on-show.yml @@ -0,0 +1,4 @@ +--- +title: Fix Milestone name on show page +merge_request: +author: Raveesh -- cgit v1.2.1 From f97c1d1001a1c16ab51fb62723f30f6ffa467d4f Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Tue, 14 Mar 2017 11:47:05 -0500 Subject: Fix link togglers jumping to top Fix #29414 --- app/assets/javascripts/behaviors/toggler_behavior.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/behaviors/toggler_behavior.js b/app/assets/javascripts/behaviors/toggler_behavior.js index 0726c6c9636..92f3bb3ff52 100644 --- a/app/assets/javascripts/behaviors/toggler_behavior.js +++ b/app/assets/javascripts/behaviors/toggler_behavior.js @@ -21,8 +21,13 @@ // %a.js-toggle-button // %div.js-toggle-content // - $('body').on('click', '.js-toggle-button', function() { + $('body').on('click', '.js-toggle-button', function(e) { toggleContainer($(this).closest('.js-toggle-container')); + + const targetTag = e.target.tagName.toLowerCase(); + if (targetTag === 'a' || targetTag === 'button') { + e.preventDefault(); + } }); // If we're accessing a permalink, ensure it is not inside a -- cgit v1.2.1 From 74ec81a4f3ba3a98946e00fd08bd72567e338271 Mon Sep 17 00:00:00 2001 From: Achilleas Pipinellis Date: Tue, 14 Mar 2017 11:25:24 +0100 Subject: Bump pages daemon to 0.4.0 [ci skip] --- doc/administration/pages/source.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/doc/administration/pages/source.md b/doc/administration/pages/source.md index b4588f8b43c..a45c3306457 100644 --- a/doc/administration/pages/source.md +++ b/doc/administration/pages/source.md @@ -105,7 +105,7 @@ The Pages daemon doesn't listen to the outside world. cd /home/git sudo -u git -H git clone https://gitlab.com/gitlab-org/gitlab-pages.git cd gitlab-pages - sudo -u git -H git checkout v0.3.2 + sudo -u git -H git checkout v$( Date: Tue, 14 Mar 2017 11:56:15 -0500 Subject: Include time tracking attributes in webhooks payload --- app/models/issue.rb | 8 +++++++- app/models/merge_request.rb | 5 ++++- .../unreleased/27271-missing-time-spent-in-issue-webhook.yml | 4 ++++ spec/models/issue_spec.rb | 11 +++++++++++ spec/models/merge_request_spec.rb | 6 +++++- 5 files changed, 31 insertions(+), 3 deletions(-) create mode 100644 changelogs/unreleased/27271-missing-time-spent-in-issue-webhook.yml diff --git a/app/models/issue.rb b/app/models/issue.rb index 0f7a26ee3e1..2cc237635f9 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -58,7 +58,13 @@ class Issue < ActiveRecord::Base end def hook_attrs - attributes + attrs = { + total_time_spent: total_time_spent, + human_total_time_spent: human_total_time_spent, + human_time_estimate: human_time_estimate + } + + attributes.merge!(attrs) end def self.reference_prefix diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 0f7b8311588..4759829a15c 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -523,7 +523,10 @@ class MergeRequest < ActiveRecord::Base source: source_project.try(:hook_attrs), target: target_project.hook_attrs, last_commit: nil, - work_in_progress: work_in_progress? + work_in_progress: work_in_progress?, + total_time_spent: total_time_spent, + human_total_time_spent: human_total_time_spent, + human_time_estimate: human_time_estimate } if diff_head_commit diff --git a/changelogs/unreleased/27271-missing-time-spent-in-issue-webhook.yml b/changelogs/unreleased/27271-missing-time-spent-in-issue-webhook.yml new file mode 100644 index 00000000000..4ea52a70e89 --- /dev/null +++ b/changelogs/unreleased/27271-missing-time-spent-in-issue-webhook.yml @@ -0,0 +1,4 @@ +--- +title: Include time tracking attributes in webhooks payload +merge_request: 9942 +author: diff --git a/spec/models/issue_spec.rb b/spec/models/issue_spec.rb index bba9058f394..898a9c8da35 100644 --- a/spec/models/issue_spec.rb +++ b/spec/models/issue_spec.rb @@ -620,4 +620,15 @@ describe Issue, models: true do end end end + + describe '#hook_attrs' do + let(:attrs_hash) { subject.hook_attrs } + + it 'includes time tracking attrs' do + expect(attrs_hash).to include(:total_time_spent) + expect(attrs_hash).to include(:human_time_estimate) + expect(attrs_hash).to include(:human_total_time_spent) + expect(attrs_hash).to include('time_estimate') + end + end end diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index fcaf4c71182..24e7c1b17d9 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -542,7 +542,7 @@ describe MergeRequest, models: true do end describe "#hook_attrs" do - let(:attrs_hash) { subject.hook_attrs.to_h } + let(:attrs_hash) { subject.hook_attrs } [:source, :target].each do |key| describe "#{key} key" do @@ -558,6 +558,10 @@ describe MergeRequest, models: true do expect(attrs_hash).to include(:target) expect(attrs_hash).to include(:last_commit) expect(attrs_hash).to include(:work_in_progress) + expect(attrs_hash).to include(:total_time_spent) + expect(attrs_hash).to include(:human_time_estimate) + expect(attrs_hash).to include(:human_total_time_spent) + expect(attrs_hash).to include('time_estimate') end end -- cgit v1.2.1 From c9abdadd7a08f972d5a12472f9f5ac443e37a6ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Coutable?= Date: Tue, 14 Mar 2017 18:08:50 +0100 Subject: Ensure dots in project path is allowed in the commits API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Rémy Coutable --- lib/api/commits.rb | 2 +- lib/api/v3/commits.rb | 2 +- spec/requests/api/commits_spec.rb | 17 +++++++++-------- spec/requests/api/v3/commits_spec.rb | 15 ++++++++------- 4 files changed, 19 insertions(+), 17 deletions(-) diff --git a/lib/api/commits.rb b/lib/api/commits.rb index 42401abfe0f..48939798900 100644 --- a/lib/api/commits.rb +++ b/lib/api/commits.rb @@ -10,7 +10,7 @@ module API params do requires :id, type: String, desc: 'The ID of a project' end - resource :projects do + resource :projects, requirements: { id: /.+/ } do desc 'Get a project repository commits' do success Entities::RepoCommit end diff --git a/lib/api/v3/commits.rb b/lib/api/v3/commits.rb index d254d247042..6f36b2bc1c4 100644 --- a/lib/api/v3/commits.rb +++ b/lib/api/v3/commits.rb @@ -11,7 +11,7 @@ module API params do requires :id, type: String, desc: 'The ID of a project' end - resource :projects do + resource :projects, requirements: { id: /.+/ } do desc 'Get a project repository commits' do success ::API::Entities::RepoCommit end diff --git a/spec/requests/api/commits_spec.rb b/spec/requests/api/commits_spec.rb index 585449e62b6..7c0f2fb9fe9 100644 --- a/spec/requests/api/commits_spec.rb +++ b/spec/requests/api/commits_spec.rb @@ -178,7 +178,7 @@ describe API::Commits, api: true do end end - describe "Create a commit with multiple files and actions" do + describe "POST /projects/:id/repository/commits" do let!(:url) { "/projects/#{project.id}/repository/commits" } it 'returns a 403 unauthorized for user without permissions' do @@ -193,7 +193,7 @@ describe API::Commits, api: true do expect(response).to have_http_status(400) end - context :create do + describe 'create' do let(:message) { 'Created file' } let!(:invalid_c_params) do { @@ -237,8 +237,9 @@ describe API::Commits, api: true do expect(response).to have_http_status(400) end - context 'with project path in URL' do - let(:url) { "/projects/#{project.full_path.gsub('/', '%2F')}/repository/commits" } + context 'with project path containing a dot in URL' do + let!(:user) { create(:user, username: 'foo.bar') } + let(:url) { "/projects/#{CGI.escape(project.full_path)}/repository/commits" } it 'a new file in project repo' do post api(url, user), valid_c_params @@ -248,7 +249,7 @@ describe API::Commits, api: true do end end - context :delete do + describe 'delete' do let(:message) { 'Deleted file' } let!(:invalid_d_params) do { @@ -289,7 +290,7 @@ describe API::Commits, api: true do end end - context :move do + describe 'move' do let(:message) { 'Moved file' } let!(:invalid_m_params) do { @@ -334,7 +335,7 @@ describe API::Commits, api: true do end end - context :update do + describe 'update' do let(:message) { 'Updated file' } let!(:invalid_u_params) do { @@ -377,7 +378,7 @@ describe API::Commits, api: true do end end - context "multiple operations" do + describe 'multiple operations' do let(:message) { 'Multiple actions' } let!(:invalid_mo_params) do { diff --git a/spec/requests/api/v3/commits_spec.rb b/spec/requests/api/v3/commits_spec.rb index e298ef055e1..adba3a787aa 100644 --- a/spec/requests/api/v3/commits_spec.rb +++ b/spec/requests/api/v3/commits_spec.rb @@ -88,7 +88,7 @@ describe API::V3::Commits, api: true do end end - describe "Create a commit with multiple files and actions" do + describe "POST /projects/:id/repository/commits" do let!(:url) { "/projects/#{project.id}/repository/commits" } it 'returns a 403 unauthorized for user without permissions' do @@ -103,7 +103,7 @@ describe API::V3::Commits, api: true do expect(response).to have_http_status(400) end - context :create do + describe 'create' do let(:message) { 'Created file' } let!(:invalid_c_params) do { @@ -147,8 +147,9 @@ describe API::V3::Commits, api: true do expect(response).to have_http_status(400) end - context 'with project path in URL' do - let(:url) { "/projects/#{project.full_path.gsub('/', '%2F')}/repository/commits" } + context 'with project path containing a dot in URL' do + let!(:user) { create(:user, username: 'foo.bar') } + let(:url) { "/projects/#{CGI.escape(project.full_path)}/repository/commits" } it 'a new file in project repo' do post v3_api(url, user), valid_c_params @@ -158,7 +159,7 @@ describe API::V3::Commits, api: true do end end - context :delete do + describe 'delete' do let(:message) { 'Deleted file' } let!(:invalid_d_params) do { @@ -199,7 +200,7 @@ describe API::V3::Commits, api: true do end end - context :move do + describe 'move' do let(:message) { 'Moved file' } let!(:invalid_m_params) do { @@ -244,7 +245,7 @@ describe API::V3::Commits, api: true do end end - context :update do + describe 'update' do let(:message) { 'Updated file' } let!(:invalid_u_params) do { -- cgit v1.2.1 From 316e0edc5ae0ecec17aa7e15b3c8b0743a216195 Mon Sep 17 00:00:00 2001 From: gpongelli Date: Tue, 14 Mar 2017 17:14:35 +0000 Subject: Syshook documentation updated --- doc/system_hooks/system_hooks.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/doc/system_hooks/system_hooks.md b/doc/system_hooks/system_hooks.md index ec13c2446ef..ad5ffc84473 100644 --- a/doc/system_hooks/system_hooks.md +++ b/doc/system_hooks/system_hooks.md @@ -313,8 +313,19 @@ X-Gitlab-Event: System Hook "git_ssh_url":"git@example.com:mike/diaspora.git", "visibility_level":0 }, - "commits": [], - "total_commits_count": 0 + "commits": [ + { + "id": "c5feabde2d8cd023215af4d2ceeb7a64839fc428", + "message": "Add simple search to projects in public area", + "timestamp": "2013-05-13T18:18:08+00:00", + "url": "https://dev.gitlab.org/gitlab/gitlabhq/commit/c5feabde2d8cd023215af4d2ceeb7a64839fc428", + "author": { + "name": "Dmitriy Zaporozhets", + "email": "dmitriy.zaporozhets@gmail.com" + } + } + ], + "total_commits_count": 1 } ``` -- cgit v1.2.1 From 464ca33747ec3f2a4063795e18ab5888e429b334 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Coutable?= Date: Tue, 14 Mar 2017 18:30:53 +0100 Subject: Allow to override GITLAB_GIT_TEST_REPO_URL to specify a different gitlab-git-test repo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We will set this to the dev mirror in GitLab CE and EE on dev. Signed-off-by: Rémy Coutable --- spec/lib/gitlab/git/repository_spec.rb | 2 +- spec/support/seed_helper.rb | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/spec/lib/gitlab/git/repository_spec.rb b/spec/lib/gitlab/git/repository_spec.rb index bc139d5ef28..9c3a4571ce4 100644 --- a/spec/lib/gitlab/git/repository_spec.rb +++ b/spec/lib/gitlab/git/repository_spec.rb @@ -507,7 +507,7 @@ describe Gitlab::Git::Repository, seed_helper: true do describe "#remote_add" do before(:all) do @repo = Gitlab::Git::Repository.new(TEST_MUTABLE_REPO_PATH) - @repo.remote_add("new_remote", SeedHelper::GITLAB_URL) + @repo.remote_add("new_remote", SeedHelper::GITLAB_GIT_TEST_REPO_URL) end it "should add the remote" do diff --git a/spec/support/seed_helper.rb b/spec/support/seed_helper.rb index 07f81e9c4f3..f55fee28ff9 100644 --- a/spec/support/seed_helper.rb +++ b/spec/support/seed_helper.rb @@ -7,7 +7,7 @@ TEST_MUTABLE_REPO_PATH = File.join(SEED_REPOSITORY_PATH, "mutable-repo.git") TEST_BROKEN_REPO_PATH = File.join(SEED_REPOSITORY_PATH, "broken-repo.git") module SeedHelper - GITLAB_URL = "https://gitlab.com/gitlab-org/gitlab-git-test.git".freeze + GITLAB_GIT_TEST_REPO_URL = ENV.fetch('GITLAB_GIT_TEST_REPO_URL', 'https://gitlab.com/gitlab-org/gitlab-git-test.git').freeze def ensure_seeds if File.exist?(SEED_REPOSITORY_PATH) @@ -25,7 +25,7 @@ module SeedHelper end def create_bare_seeds - system(git_env, *%W(#{Gitlab.config.git.bin_path} clone --bare #{GITLAB_URL}), + system(git_env, *%W(#{Gitlab.config.git.bin_path} clone --bare #{GITLAB_GIT_TEST_REPO_URL}), chdir: SEED_REPOSITORY_PATH, out: '/dev/null', err: '/dev/null') @@ -45,7 +45,7 @@ module SeedHelper system(git_env, *%w(git branch -t feature origin/feature), chdir: TEST_MUTABLE_REPO_PATH, out: '/dev/null', err: '/dev/null') - system(git_env, *%W(#{Gitlab.config.git.bin_path} remote add expendable #{GITLAB_URL}), + system(git_env, *%W(#{Gitlab.config.git.bin_path} remote add expendable #{GITLAB_GIT_TEST_REPO_URL}), chdir: TEST_MUTABLE_REPO_PATH, out: '/dev/null', err: '/dev/null') end -- cgit v1.2.1 From ee2ddd059520f2c9a875c888a2c4eb44af3643a5 Mon Sep 17 00:00:00 2001 From: Jose Ivan Vargas Date: Fri, 10 Mar 2017 18:02:35 -0600 Subject: Moved the gear settings dropdown in the group view to a tab --- app/views/groups/_settings_head.html.haml | 14 ++++++++++++++ app/views/groups/edit.html.haml | 1 + app/views/groups/projects.html.haml | 1 + app/views/layouts/nav/_group.html.haml | 9 ++++++++- app/views/layouts/nav/_group_settings.html.haml | 18 ------------------ .../unreleased/group-gear-setting-dropdown-to-tab.yml | 4 ++++ 6 files changed, 28 insertions(+), 19 deletions(-) create mode 100644 app/views/groups/_settings_head.html.haml delete mode 100644 app/views/layouts/nav/_group_settings.html.haml create mode 100644 changelogs/unreleased/group-gear-setting-dropdown-to-tab.yml diff --git a/app/views/groups/_settings_head.html.haml b/app/views/groups/_settings_head.html.haml new file mode 100644 index 00000000000..dc11efeb0c4 --- /dev/null +++ b/app/views/groups/_settings_head.html.haml @@ -0,0 +1,14 @@ += content_for :sub_nav do + .scrolling-tabs-container.sub-nav-scroll + = render 'shared/nav_scroll' + .nav-links.sub-nav.scrolling-tabs + %ul{ class: container_class } + = nav_link(path: 'groups#projects') do + = link_to projects_group_path(@group), title: 'Projects' do + %span + Projects + + = nav_link(path: 'groups#edit') do + = link_to edit_group_path(@group), title: 'Edit Group' do + %span + Edit Group \ No newline at end of file diff --git a/app/views/groups/edit.html.haml b/app/views/groups/edit.html.haml index 2706e8692d1..80a77dab97f 100644 --- a/app/views/groups/edit.html.haml +++ b/app/views/groups/edit.html.haml @@ -1,3 +1,4 @@ += render "groups/settings_head" .panel.panel-default.prepend-top-default .panel-heading Group settings diff --git a/app/views/groups/projects.html.haml b/app/views/groups/projects.html.haml index 2e7e5e5c309..1f4a3e2a829 100644 --- a/app/views/groups/projects.html.haml +++ b/app/views/groups/projects.html.haml @@ -1,4 +1,5 @@ - page_title "Projects" += render "groups/settings_head" .panel.panel-default.prepend-top-default .panel-heading diff --git a/app/views/layouts/nav/_group.html.haml b/app/views/layouts/nav/_group.html.haml index a6e96942021..9de0e344196 100644 --- a/app/views/layouts/nav/_group.html.haml +++ b/app/views/layouts/nav/_group.html.haml @@ -1,4 +1,5 @@ -= render 'layouts/nav/group_settings' +- can_admin_group = can?(current_user, :admin_group, @group) +- can_edit = can?(current_user, :admin_group, @group) .scrolling-tabs-container{ class: nav_control_class } .fade-left = icon('angle-left') @@ -25,3 +26,9 @@ = link_to group_group_members_path(@group), title: 'Members' do %span Members + - if current_user + - if can_admin_group || can_edit + = nav_link(path: %w[groups#projects groups#edit]) do + = link_to projects_group_path(@group), title: 'Settings' do + %span + Settings diff --git a/app/views/layouts/nav/_group_settings.html.haml b/app/views/layouts/nav/_group_settings.html.haml deleted file mode 100644 index 30feb6813b4..00000000000 --- a/app/views/layouts/nav/_group_settings.html.haml +++ /dev/null @@ -1,18 +0,0 @@ -- if current_user - - can_admin_group = can?(current_user, :admin_group, @group) - - can_edit = can?(current_user, :admin_group, @group) - - - if can_admin_group || can_edit - .controls - .dropdown.group-settings-dropdown - %a.dropdown-new.btn.btn-default#group-settings-button{ href: '#', 'data-toggle' => 'dropdown' } - = icon('cog') - = icon('caret-down') - %ul.dropdown-menu.dropdown-menu-align-right - - if can_admin_group - = nav_link(path: 'groups#projects') do - = link_to 'Projects', projects_group_path(@group), title: 'Projects' - - if can_edit && can_admin_group - %li.divider - %li - = link_to 'Edit Group', edit_group_path(@group) diff --git a/changelogs/unreleased/group-gear-setting-dropdown-to-tab.yml b/changelogs/unreleased/group-gear-setting-dropdown-to-tab.yml new file mode 100644 index 00000000000..aff1bdd957c --- /dev/null +++ b/changelogs/unreleased/group-gear-setting-dropdown-to-tab.yml @@ -0,0 +1,4 @@ +--- +title: Moved the gear settings dropdown to a tab in the groups view +merge_request: +author: -- cgit v1.2.1 From f47946591a52536c7dd7d02d11ffb7390549470b Mon Sep 17 00:00:00 2001 From: Jose Ivan Vargas Date: Fri, 10 Mar 2017 18:40:33 -0600 Subject: Fixed haml_lint warning for the settings_head partial --- app/views/groups/_settings_head.html.haml | 2 +- app/views/groups/projects.html.haml | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/app/views/groups/_settings_head.html.haml b/app/views/groups/_settings_head.html.haml index dc11efeb0c4..d225f7ed3c0 100644 --- a/app/views/groups/_settings_head.html.haml +++ b/app/views/groups/_settings_head.html.haml @@ -11,4 +11,4 @@ = nav_link(path: 'groups#edit') do = link_to edit_group_path(@group), title: 'Edit Group' do %span - Edit Group \ No newline at end of file + Edit Group diff --git a/app/views/groups/projects.html.haml b/app/views/groups/projects.html.haml index 1f4a3e2a829..83bdd654f27 100644 --- a/app/views/groups/projects.html.haml +++ b/app/views/groups/projects.html.haml @@ -1,4 +1,3 @@ -- page_title "Projects" = render "groups/settings_head" .panel.panel-default.prepend-top-default -- cgit v1.2.1 From 30f99608ffa5a4ce3d403276df5d68a23ec9b338 Mon Sep 17 00:00:00 2001 From: Jose Ivan Vargas Date: Tue, 14 Mar 2017 12:00:00 -0600 Subject: Fixed some missing permission conditions --- app/views/groups/_settings_head.html.haml | 11 +++++++---- app/views/layouts/nav/_group.html.haml | 12 +++++------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/app/views/groups/_settings_head.html.haml b/app/views/groups/_settings_head.html.haml index d225f7ed3c0..d99426bc2c1 100644 --- a/app/views/groups/_settings_head.html.haml +++ b/app/views/groups/_settings_head.html.haml @@ -1,3 +1,5 @@ +- can_admin_group = can?(current_user, :admin_group, @group) +- can_edit = can?(current_user, :admin_group, @group) = content_for :sub_nav do .scrolling-tabs-container.sub-nav-scroll = render 'shared/nav_scroll' @@ -8,7 +10,8 @@ %span Projects - = nav_link(path: 'groups#edit') do - = link_to edit_group_path(@group), title: 'Edit Group' do - %span - Edit Group + - if can_edit && can_admin_group + = nav_link(path: 'groups#edit') do + = link_to edit_group_path(@group), title: 'Edit Group' do + %span + Edit Group diff --git a/app/views/layouts/nav/_group.html.haml b/app/views/layouts/nav/_group.html.haml index 9de0e344196..b2ecf6504e0 100644 --- a/app/views/layouts/nav/_group.html.haml +++ b/app/views/layouts/nav/_group.html.haml @@ -1,5 +1,4 @@ - can_admin_group = can?(current_user, :admin_group, @group) -- can_edit = can?(current_user, :admin_group, @group) .scrolling-tabs-container{ class: nav_control_class } .fade-left = icon('angle-left') @@ -26,9 +25,8 @@ = link_to group_group_members_path(@group), title: 'Members' do %span Members - - if current_user - - if can_admin_group || can_edit - = nav_link(path: %w[groups#projects groups#edit]) do - = link_to projects_group_path(@group), title: 'Settings' do - %span - Settings + - if current_user && can_admin_group + = nav_link(path: %w[groups#projects groups#edit]) do + = link_to projects_group_path(@group), title: 'Settings' do + %span + Settings -- cgit v1.2.1 From bb99fc2572a796b938bd67128f6482c180e3942b Mon Sep 17 00:00:00 2001 From: Achilleas Pipinellis Date: Tue, 7 Mar 2017 21:32:36 +0100 Subject: Add nested groups documentation [ci skip] --- doc/user/group/subgroups/img/create_new_group.png | Bin 0 -> 18503 bytes .../group/subgroups/img/create_subgroup_button.png | Bin 0 -> 8402 bytes doc/user/group/subgroups/img/group_members.png | Bin 0 -> 48240 bytes doc/user/group/subgroups/img/mention_subgroups.png | Bin 0 -> 39666 bytes doc/user/group/subgroups/index.md | 153 +++++++++++++++++++++ doc/user/permissions.md | 1 + doc/workflow/README.md | 1 + 7 files changed, 155 insertions(+) create mode 100644 doc/user/group/subgroups/img/create_new_group.png create mode 100644 doc/user/group/subgroups/img/create_subgroup_button.png create mode 100644 doc/user/group/subgroups/img/group_members.png create mode 100644 doc/user/group/subgroups/img/mention_subgroups.png create mode 100644 doc/user/group/subgroups/index.md diff --git a/doc/user/group/subgroups/img/create_new_group.png b/doc/user/group/subgroups/img/create_new_group.png new file mode 100644 index 00000000000..9d011ec709a Binary files /dev/null and b/doc/user/group/subgroups/img/create_new_group.png differ diff --git a/doc/user/group/subgroups/img/create_subgroup_button.png b/doc/user/group/subgroups/img/create_subgroup_button.png new file mode 100644 index 00000000000..000b54c2855 Binary files /dev/null and b/doc/user/group/subgroups/img/create_subgroup_button.png differ diff --git a/doc/user/group/subgroups/img/group_members.png b/doc/user/group/subgroups/img/group_members.png new file mode 100644 index 00000000000..b95fe6263bf Binary files /dev/null and b/doc/user/group/subgroups/img/group_members.png differ diff --git a/doc/user/group/subgroups/img/mention_subgroups.png b/doc/user/group/subgroups/img/mention_subgroups.png new file mode 100644 index 00000000000..8e6bed0111b Binary files /dev/null and b/doc/user/group/subgroups/img/mention_subgroups.png differ diff --git a/doc/user/group/subgroups/index.md b/doc/user/group/subgroups/index.md new file mode 100644 index 00000000000..9522e6fc4ba --- /dev/null +++ b/doc/user/group/subgroups/index.md @@ -0,0 +1,153 @@ +# Subgroups + +> [Introduced][ce-2772] in GitLab 9.0. + +With subgroups (also called nested groups or hierarchical groups) you can have +up to 20 levels of nested groups, which among other things can help you to: + +- **Separate internal / external organizations.** Since every group + can have its own visibility level, you are able to host groups for different + purposes under the same umbrella. +- **Organize large projects.** For large projects, subgroups makes it + potentially easier to separate permissions on parts of the source code. +- **Make it easier to manage people and control visibility.** Give people + different [permissions][] depending on their group [membership](#membership). + +## Overview + +A group can have many subgroups inside it, and at the same time a group can have +only 1 parent group. It resembles a directory behavior, like the one below: + +``` +group0 +└── subgroup01a +└── subgroup01b + └── subgroup02 + └── subgroup03 +``` + +In a real world example, imagine maintaining a GNU/Linux distribution with the +first group being the name of the distro and subsequent groups split like: + +``` +Organization Group - GNU/Linux distro + └── Category Subgroup - Packages + └── project - Package01 + └── project - Package02 + └── Category Subgroup - Software + └── project - Core + └── project - CLI + └── project - Android app + └── project - iOS app + └── Category Subgroup - Infra tools + └── project - Ansible playbooks +``` + +Another example of GitLab as a company would be the following: + +``` +Organization Group - GitLab + └── Category Subroup - Marketing + └── project - Design + └── project - General + └── Category Subgroup - Software + └── project - GitLab CE + └── project - GitLab EE + └── project - Omnibus GitLab + └── project - GitLab Runner + └── project - GitLab Pages daemon + └── Category Subgroup - Infra tools + └── project - Chef cookbooks + └── Category Subgroup - Executive team +``` + +--- + +The maximum nested groups a group can have, including the first one in the +hierarchy, is 21. + +Things like transferring or importing a project inside nested groups, work like +when performing these actions the traditional way with the `group/project` +structure. + +## Creating a subgroup + +>**Notes:** +- You need to be an Owner of a group in order to be able to create + a subgroup. For more information check the [permissions table][permissions]. +- For a list of words that are not allowed to be used as group names see the + [`namespace_validator.rb` file][reserved] under the `RESERVED` and + `WILDCARD_ROUTES` lists. + +To create a subgroup: + +1. In the group's dashboard go to the **Subgroups** page and click **Create subgroup**. + + ![Subgroups page](img/create_subgroup_button.png) + +1. Create a new group like you would normally do. Notice that the parent group + namespace is fixed under **Group path**. The visibility level can differ from + the parent group. + + ![Subgroups page](img/create_new_group.png) + +1. Click the **Create group** button and you will be taken to the new group's + dashboard page. + +--- + +You can follow the same process to create any subsequent groups. + +## Membership + +When you add a member to a subgroup, they inherit the membership and permission +level from the parent group. This model allows access to nested groups if you +have membership in one of its parents. + +You can tell if a member has inherited the permissions from a parent group by +looking at the group's **Members** page. + +![Group members page](img/group_members.png) + +From the image above, we can deduct the following things: + +- There are 5 members that have access to the group **four** +- Administrator is the Owner and member of all subgroups +- User0 is a Reporter and has inherited their permissions from group **one** + which is above the hierarchy of group **four** +- User1 is a Developer and has inherited their permissions from group + **one/two** which is above the hierarchy of group **four** +- User2 is a Developer and has inherited their permissions from group + **one/two/three** which is above the hierarchy of group **four** +- User3 is a Master of group **four**, there is no indication of a parent + group therefore they belong to group **four** + +The group permissions for a member can be changed only by Owners and only on +the **Members** page of the group the member was added. + +## Mentioning subgroups + +Mentioning groups (`@group`) in issues, commits and merge requests, would +mention all members of that group. Now with subgroups, there is a more granular +support if you want to split your group's structure. Mentioning works as before +and you can choose the group of people to be summoned. + +![Mentioning subgroups](img/mention_subgroups.png) + +## Limitations + +Here's a list of what you can't do with subgroups: + +- [GitLab Pages](../../project/pages/index.md) are not currently working for + projects hosted under a subgroup. That means that only projects hosted under + the first parent group will work. +- Group level labels don't work in subgroups / sub projects +- It is not possible to share a project with a group that's an ancestor of + the group the project is in. That means you can only share as you walk down + the hierarchy. For example, `group/subgroup01/project` **cannot** be shared + with `group`, but can be shared with `group/subgroup02` or + `group/subgroup01/subgroup03`. + +[ce-2772]: https://gitlab.com/gitlab-org/gitlab-ce/issues/2772 +[permissions]: ../../permissions.md#group +[reserved]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/app/validators/namespace_validator.rb diff --git a/doc/user/permissions.md b/doc/user/permissions.md index b49a244160a..0ea6d01411f 100644 --- a/doc/user/permissions.md +++ b/doc/user/permissions.md @@ -81,6 +81,7 @@ group. |-------------------------|-------|----------|-----------|--------|-------| | Browse group | ✓ | ✓ | ✓ | ✓ | ✓ | | Edit group | | | | | ✓ | +| Create subgroup | | | | | ✓ | | Create project in group | | | | ✓ | ✓ | | Manage group members | | | | | ✓ | | Remove group | | | | | ✓ | diff --git a/doc/workflow/README.md b/doc/workflow/README.md index 9e7ee47387c..a286a23765d 100644 --- a/doc/workflow/README.md +++ b/doc/workflow/README.md @@ -40,3 +40,4 @@ - [Importing from SVN, GitHub, Bitbucket, etc](importing/README.md) - [Todos](todos.md) - [Snippets](../user/snippets.md) +- [Nested groups](../user/group/subgroups/index.md) -- cgit v1.2.1 From 1913f1ed9efced37cc597515ea4c7219eb17b4be Mon Sep 17 00:00:00 2001 From: Achilleas Pipinellis Date: Fri, 10 Mar 2017 14:15:28 +0100 Subject: Add info on group membership [ci skip] --- doc/user/group/subgroups/index.md | 39 +++++++++++++++++++++++++++++---------- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/doc/user/group/subgroups/index.md b/doc/user/group/subgroups/index.md index 9522e6fc4ba..2338d8e9b42 100644 --- a/doc/user/group/subgroups/index.md +++ b/doc/user/group/subgroups/index.md @@ -104,6 +104,9 @@ When you add a member to a subgroup, they inherit the membership and permission level from the parent group. This model allows access to nested groups if you have membership in one of its parents. +The group permissions for a member can be changed only by Owners and only on +the **Members** page of the group the member was added. + You can tell if a member has inherited the permissions from a parent group by looking at the group's **Members** page. @@ -111,19 +114,35 @@ looking at the group's **Members** page. From the image above, we can deduct the following things: -- There are 5 members that have access to the group **four** -- Administrator is the Owner and member of all subgroups -- User0 is a Reporter and has inherited their permissions from group **one** - which is above the hierarchy of group **four** +- There are 5 members that have access to the group `four` +- User0 is a Reporter and has inherited their permissions from group `one` + which is above the hierarchy of group `four` - User1 is a Developer and has inherited their permissions from group - **one/two** which is above the hierarchy of group **four** + `one/two` which is above the hierarchy of group `four` - User2 is a Developer and has inherited their permissions from group - **one/two/three** which is above the hierarchy of group **four** -- User3 is a Master of group **four**, there is no indication of a parent - group therefore they belong to group **four** + `one/two/three` which is above the hierarchy of group `four` +- For User3 there is no indication of a parent group, therefore they belong to + group `four`, the one we're inspecting +- Administrator is the Owner and member of **all** subgroups and for that reason, + same as User3, there is no indication of an ancestor group -The group permissions for a member can be changed only by Owners and only on -the **Members** page of the group the member was added. +### Overriding the ancestor group membership + +>**Note:** +You need to be an Owner of a group in order to be able to add members to it. + +To override the membership of an ancestor group, simply add the user in the new +subgroup again, but with different permissions. + +For example, if User0 was first added to group `one/two` with Developer +permissions, then they will inherit those permissions in every other subgroup +of `one/two`. To give them Master access to `one/two/three`, you would add them +again in that group as Master. Removing them from that group, the permissions +will fallback to those of the ancestor group. + +Note that the higher permission wins, so if in the above example the permissions +where reversed, User0 would have Master access to all groups, even to the one +that was explicitly given Developer access. ## Mentioning subgroups -- cgit v1.2.1 From f35d7a16595c199add9a582b8a80f8da75e5544d Mon Sep 17 00:00:00 2001 From: Achilleas Pipinellis Date: Fri, 10 Mar 2017 14:18:48 +0100 Subject: Fix wording [ci skip] --- doc/user/group/subgroups/index.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/user/group/subgroups/index.md b/doc/user/group/subgroups/index.md index 2338d8e9b42..ff28f458f99 100644 --- a/doc/user/group/subgroups/index.md +++ b/doc/user/group/subgroups/index.md @@ -147,9 +147,9 @@ that was explicitly given Developer access. ## Mentioning subgroups Mentioning groups (`@group`) in issues, commits and merge requests, would -mention all members of that group. Now with subgroups, there is a more granular +notify all members of that group. Now with subgroups, there is a more granular support if you want to split your group's structure. Mentioning works as before -and you can choose the group of people to be summoned. +and you can choose the group of people to be notified. ![Mentioning subgroups](img/mention_subgroups.png) -- cgit v1.2.1 From b5142f92a03c1b2a58ee583e2d0632150e74d45a Mon Sep 17 00:00:00 2001 From: Achilleas Pipinellis Date: Tue, 14 Mar 2017 21:41:46 +0100 Subject: Address subgroups docs review [ci skip] --- doc/user/group/subgroups/index.md | 86 ++++++++++++++++++--------------------- 1 file changed, 39 insertions(+), 47 deletions(-) diff --git a/doc/user/group/subgroups/index.md b/doc/user/group/subgroups/index.md index ff28f458f99..ce5da07c61a 100644 --- a/doc/user/group/subgroups/index.md +++ b/doc/user/group/subgroups/index.md @@ -2,7 +2,7 @@ > [Introduced][ce-2772] in GitLab 9.0. -With subgroups (also called nested groups or hierarchical groups) you can have +With subgroups (aka nested groups or hierarchical groups) you can have up to 20 levels of nested groups, which among other things can help you to: - **Separate internal / external organizations.** Since every group @@ -16,50 +16,45 @@ up to 20 levels of nested groups, which among other things can help you to: ## Overview A group can have many subgroups inside it, and at the same time a group can have -only 1 parent group. It resembles a directory behavior, like the one below: +only 1 parent group. It resembles a directory behavior or a nested items list: -``` -group0 -└── subgroup01a -└── subgroup01b - └── subgroup02 - └── subgroup03 -``` +- Group 1 + - Group 1.1 + - Group 1.2 + - Group 1.2.1 + - Group 1.2.2 + - Group 1.2.2.1 In a real world example, imagine maintaining a GNU/Linux distribution with the first group being the name of the distro and subsequent groups split like: -``` -Organization Group - GNU/Linux distro - └── Category Subgroup - Packages - └── project - Package01 - └── project - Package02 - └── Category Subgroup - Software - └── project - Core - └── project - CLI - └── project - Android app - └── project - iOS app - └── Category Subgroup - Infra tools - └── project - Ansible playbooks -``` +- Organization Group - GNU/Linux distro + - Category Subgroup - Packages + - (project) Package01 + - (project) Package02 + - Category Subgroup - Software + - (project) Core + - (project) CLI + - (project) Android app + - (project) iOS app + - Category Subgroup - Infra tools + - (project) Ansible playbooks Another example of GitLab as a company would be the following: -``` -Organization Group - GitLab - └── Category Subroup - Marketing - └── project - Design - └── project - General - └── Category Subgroup - Software - └── project - GitLab CE - └── project - GitLab EE - └── project - Omnibus GitLab - └── project - GitLab Runner - └── project - GitLab Pages daemon - └── Category Subgroup - Infra tools - └── project - Chef cookbooks - └── Category Subgroup - Executive team -``` +- Organization Group - GitLab + - Category Subroup - Marketing + - (project) Design + - (project) General + - Category Subgroup - Software + - (project) GitLab CE + - (project) GitLab EE + - (project) Omnibus GitLab + - (project) GitLab Runner + - (project) GitLab Pages daemon + - Category Subgroup - Infra tools + - (project) Chef cookbooks + - Category Subgroup - Executive team --- @@ -131,18 +126,15 @@ From the image above, we can deduct the following things: >**Note:** You need to be an Owner of a group in order to be able to add members to it. -To override the membership of an ancestor group, simply add the user in the new -subgroup again, but with different permissions. +To override a user's membership of an ancestor group (the first group they were +added to), simply add the user in the new subgroup again, but with different +permissions. -For example, if User0 was first added to group `one/two` with Developer +For example, if User0 was first added to group `group-1/group-1-1` with Developer permissions, then they will inherit those permissions in every other subgroup -of `one/two`. To give them Master access to `one/two/three`, you would add them -again in that group as Master. Removing them from that group, the permissions -will fallback to those of the ancestor group. - -Note that the higher permission wins, so if in the above example the permissions -where reversed, User0 would have Master access to all groups, even to the one -that was explicitly given Developer access. +of `group-1/group-1-1`. To give them Master access to `group-1/group-1-1/group1-1-1`, +you would add them again in that group as Master. Removing them from that group, +the permissions will fallback to those of the ancestor group. ## Mentioning subgroups -- cgit v1.2.1 From 6890327762eaeca572ada783804a9c7af01e6144 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 10 Mar 2017 15:34:29 -0600 Subject: Copy code as GFM from diffs, blobs and GFM code blocks --- app/assets/javascripts/copy_as_gfm.js | 72 ++- changelogs/unreleased/dm-copy-code-as-gfm.yml | 4 + lib/banzai/filter/syntax_highlight_filter.rb | 13 +- lib/gitlab/highlight.rb | 4 +- lib/rouge/formatters/html_gitlab.rb | 5 +- spec/features/copy_as_gfm_spec.rb | 782 +++++++++++++++----------- 6 files changed, 537 insertions(+), 343 deletions(-) create mode 100644 changelogs/unreleased/dm-copy-code-as-gfm.yml diff --git a/app/assets/javascripts/copy_as_gfm.js b/app/assets/javascripts/copy_as_gfm.js index 0fb7bde1fd6..67f7226fe82 100644 --- a/app/assets/javascripts/copy_as_gfm.js +++ b/app/assets/javascripts/copy_as_gfm.js @@ -118,10 +118,10 @@ const gfmRules = { }, SyntaxHighlightFilter: { 'pre.code.highlight'(el, t) { - const text = t.trim(); + const text = t.trimRight(); let lang = el.getAttribute('lang'); - if (lang === 'plaintext') { + if (!lang || lang === 'plaintext') { lang = ''; } @@ -157,7 +157,7 @@ const gfmRules = { const backticks = Array(backtickCount + 1).join('`'); const spaceOrNoSpace = backtickCount > 1 ? ' ' : ''; - return backticks + spaceOrNoSpace + text + spaceOrNoSpace + backticks; + return backticks + spaceOrNoSpace + text.trim() + spaceOrNoSpace + backticks; }, 'blockquote'(el, text) { return text.trim().split('\n').map(s => `> ${s}`.trim()).join('\n'); @@ -273,28 +273,29 @@ const gfmRules = { class CopyAsGFM { constructor() { - $(document).on('copy', '.md, .wiki', this.handleCopy); - $(document).on('paste', '.js-gfm-input', this.handlePaste); + $(document).on('copy', '.md, .wiki', (e) => { this.copyAsGFM(e, CopyAsGFM.transformGFMSelection); }); + $(document).on('copy', 'pre.code.highlight, .diff-content .line_content', (e) => { this.copyAsGFM(e, CopyAsGFM.transformCodeSelection); }); + $(document).on('paste', '.js-gfm-input', this.pasteGFM.bind(this)); } - handleCopy(e) { + copyAsGFM(e, transformer) { const clipboardData = e.originalEvent.clipboardData; if (!clipboardData) return; const documentFragment = window.gl.utils.getSelectedFragment(); if (!documentFragment) return; - // If the documentFragment contains more than just Markdown, don't copy as GFM. - if (documentFragment.querySelector('.md, .wiki')) return; + const el = transformer(documentFragment.cloneNode(true)); + if (!el) return; e.preventDefault(); - clipboardData.setData('text/plain', documentFragment.textContent); + e.stopPropagation(); - const gfm = CopyAsGFM.nodeToGFM(documentFragment); - clipboardData.setData('text/x-gfm', gfm); + clipboardData.setData('text/plain', el.textContent); + clipboardData.setData('text/x-gfm', CopyAsGFM.nodeToGFM(el)); } - handlePaste(e) { + pasteGFM(e) { const clipboardData = e.originalEvent.clipboardData; if (!clipboardData) return; @@ -306,7 +307,54 @@ class CopyAsGFM { window.gl.utils.insertText(e.target, gfm); } + static transformGFMSelection(documentFragment) { + // If the documentFragment contains more than just Markdown, don't copy as GFM. + if (documentFragment.querySelector('.md, .wiki')) return null; + + return documentFragment; + } + + static transformCodeSelection(documentFragment) { + const lineEls = documentFragment.querySelectorAll('.line'); + + let codeEl; + if (lineEls.length > 1) { + codeEl = document.createElement('pre'); + codeEl.className = 'code highlight'; + + const lang = lineEls[0].getAttribute('lang'); + if (lang) { + codeEl.setAttribute('lang', lang); + } + } else { + codeEl = document.createElement('code'); + } + + if (lineEls.length > 0) { + for (let i = 0; i < lineEls.length; i += 1) { + const lineEl = lineEls[i]; + codeEl.appendChild(lineEl); + codeEl.appendChild(document.createTextNode('\n')); + } + } else { + codeEl.appendChild(documentFragment); + } + + return codeEl; + } + + static selectionToGFM(documentFragment, transformer) { + const el = transformer(documentFragment.cloneNode(true)); + if (!el) return null; + + return CopyAsGFM.nodeToGFM(el); + } + static nodeToGFM(node) { + if (node.nodeType === Node.COMMENT_NODE) { + return ''; + } + if (node.nodeType === Node.TEXT_NODE) { return node.textContent; } diff --git a/changelogs/unreleased/dm-copy-code-as-gfm.yml b/changelogs/unreleased/dm-copy-code-as-gfm.yml new file mode 100644 index 00000000000..15ae2da44a3 --- /dev/null +++ b/changelogs/unreleased/dm-copy-code-as-gfm.yml @@ -0,0 +1,4 @@ +--- +title: Copy code as GFM from diffs, blobs and GFM code blocks +merge_request: +author: diff --git a/lib/banzai/filter/syntax_highlight_filter.rb b/lib/banzai/filter/syntax_highlight_filter.rb index a447e2b8bff..9f09ca90697 100644 --- a/lib/banzai/filter/syntax_highlight_filter.rb +++ b/lib/banzai/filter/syntax_highlight_filter.rb @@ -5,8 +5,6 @@ module Banzai # HTML Filter to highlight fenced code blocks # class SyntaxHighlightFilter < HTML::Pipeline::Filter - include Rouge::Plugins::Redcarpet - def call doc.search('pre > code').each do |node| highlight_node(node) @@ -23,7 +21,7 @@ module Banzai lang = lexer.tag begin - code = format(lex(lexer, code)) + code = Rouge::Formatters::HTMLGitlab.format(lex(lexer, code), tag: lang) css_classes << " js-syntax-highlight #{lang}" rescue @@ -45,10 +43,6 @@ module Banzai lexer.lex(code) end - def format(tokens) - rouge_formatter.format(tokens) - end - def lexer_for(language) (Rouge::Lexer.find(language) || Rouge::Lexers::PlainText).new end @@ -57,11 +51,6 @@ module Banzai # Replace the parent `pre` element with the entire highlighted block node.parent.replace(highlighted) end - - # Override Rouge::Plugins::Redcarpet#rouge_formatter - def rouge_formatter(lexer = nil) - @rouge_formatter ||= Rouge::Formatters::HTML.new - end end end end diff --git a/lib/gitlab/highlight.rb b/lib/gitlab/highlight.rb index 9360afedfcb..d787d5db4a0 100644 --- a/lib/gitlab/highlight.rb +++ b/lib/gitlab/highlight.rb @@ -14,7 +14,7 @@ module Gitlab end def initialize(blob_name, blob_content, repository: nil) - @formatter = Rouge::Formatters::HTMLGitlab.new + @formatter = Rouge::Formatters::HTMLGitlab @repository = repository @blob_name = blob_name @blob_content = blob_content @@ -28,7 +28,7 @@ module Gitlab hl_lexer = self.lexer end - @formatter.format(hl_lexer.lex(text, continue: continue)).html_safe + @formatter.format(hl_lexer.lex(text, continue: continue), tag: hl_lexer.tag).html_safe rescue @formatter.format(Rouge::Lexers::PlainText.lex(text)).html_safe end diff --git a/lib/rouge/formatters/html_gitlab.rb b/lib/rouge/formatters/html_gitlab.rb index 4edfd015074..ec95ddf03ea 100644 --- a/lib/rouge/formatters/html_gitlab.rb +++ b/lib/rouge/formatters/html_gitlab.rb @@ -6,9 +6,10 @@ module Rouge # Creates a new Rouge::Formatter::HTMLGitlab instance. # # [+linenostart+] The line number for the first line (default: 1). - def initialize(linenostart: 1) + def initialize(linenostart: 1, tag: nil) @linenostart = linenostart @line_number = linenostart + @tag = tag end def stream(tokens, &b) @@ -17,7 +18,7 @@ module Rouge yield "\n" unless is_first is_first = false - yield %() + yield %() line.each { |token, value| yield span(token, value.chomp) } yield %() diff --git a/spec/features/copy_as_gfm_spec.rb b/spec/features/copy_as_gfm_spec.rb index 4638812b2d9..f134d4be154 100644 --- a/spec/features/copy_as_gfm_spec.rb +++ b/spec/features/copy_as_gfm_spec.rb @@ -2,437 +2,589 @@ require 'spec_helper' describe 'Copy as GFM', feature: true, js: true do include GitlabMarkdownHelper + include RepoHelpers include ActionView::Helpers::JavaScriptHelper before do - @feat = MarkdownFeature.new + login_as :admin + end - # `markdown` helper expects a `@project` variable - @project = @feat.project + describe 'Copying rendered GFM' do + before do + @feat = MarkdownFeature.new - visit namespace_project_issue_path(@project.namespace, @project, @feat.issue) - end + # `markdown` helper expects a `@project` variable + @project = @feat.project - # The filters referenced in lib/banzai/pipeline/gfm_pipeline.rb convert GitLab Flavored Markdown (GFM) to HTML. - # The handlers defined in app/assets/javascripts/copy_as_gfm.js.es6 consequently convert that same HTML to GFM. - # To make sure these filters and handlers are properly aligned, this spec tests the GFM-to-HTML-to-GFM cycle - # by verifying (`html_to_gfm(gfm_to_html(gfm)) == gfm`) for a number of examples of GFM for every filter, using the `verify` helper. + visit namespace_project_issue_path(@project.namespace, @project, @feat.issue) + end - # These are all in a single `it` for performance reasons. - it 'works', :aggregate_failures do - verify( - 'nesting', + # The filters referenced in lib/banzai/pipeline/gfm_pipeline.rb convert GitLab Flavored Markdown (GFM) to HTML. + # The handlers defined in app/assets/javascripts/copy_as_gfm.js.es6 consequently convert that same HTML to GFM. + # To make sure these filters and handlers are properly aligned, this spec tests the GFM-to-HTML-to-GFM cycle + # by verifying (`html_to_gfm(gfm_to_html(gfm)) == gfm`) for a number of examples of GFM for every filter, using the `verify` helper. - '> 1. [x] **[$`2 + 2`$ {-=-}{+=+} 2^2 ~~:thumbsup:~~](http://google.com)**' - ) + # These are all in a single `it` for performance reasons. + it 'works', :aggregate_failures do + verify( + 'nesting', - verify( - 'a real world example from the gitlab-ce README', + '> 1. [x] **[$`2 + 2`$ {-=-}{+=+} 2^2 ~~:thumbsup:~~](http://google.com)**' + ) - <<-GFM.strip_heredoc - # GitLab + verify( + 'a real world example from the gitlab-ce README', - [![Build status](https://gitlab.com/gitlab-org/gitlab-ce/badges/master/build.svg)](https://gitlab.com/gitlab-org/gitlab-ce/commits/master) - [![CE coverage report](https://gitlab.com/gitlab-org/gitlab-ce/badges/master/coverage.svg?job=coverage)](https://gitlab-org.gitlab.io/gitlab-ce/coverage-ruby) - [![Code Climate](https://codeclimate.com/github/gitlabhq/gitlabhq.svg)](https://codeclimate.com/github/gitlabhq/gitlabhq) - [![Core Infrastructure Initiative Best Practices](https://bestpractices.coreinfrastructure.org/projects/42/badge)](https://bestpractices.coreinfrastructure.org/projects/42) + <<-GFM.strip_heredoc + # GitLab - ## Canonical source + [![Build status](https://gitlab.com/gitlab-org/gitlab-ce/badges/master/build.svg)](https://gitlab.com/gitlab-org/gitlab-ce/commits/master) + [![CE coverage report](https://gitlab.com/gitlab-org/gitlab-ce/badges/master/coverage.svg?job=coverage)](https://gitlab-org.gitlab.io/gitlab-ce/coverage-ruby) + [![Code Climate](https://codeclimate.com/github/gitlabhq/gitlabhq.svg)](https://codeclimate.com/github/gitlabhq/gitlabhq) + [![Core Infrastructure Initiative Best Practices](https://bestpractices.coreinfrastructure.org/projects/42/badge)](https://bestpractices.coreinfrastructure.org/projects/42) - The canonical source of GitLab Community Edition is [hosted on GitLab.com](https://gitlab.com/gitlab-org/gitlab-ce/). + ## Canonical source - ## Open source software to collaborate on code + The canonical source of GitLab Community Edition is [hosted on GitLab.com](https://gitlab.com/gitlab-org/gitlab-ce/). - To see how GitLab looks please see the [features page on our website](https://about.gitlab.com/features/). + ## Open source software to collaborate on code + To see how GitLab looks please see the [features page on our website](https://about.gitlab.com/features/). - - Manage Git repositories with fine grained access controls that keep your code secure - - Perform code reviews and enhance collaboration with merge requests + - Manage Git repositories with fine grained access controls that keep your code secure - - Complete continuous integration (CI) and CD pipelines to builds, test, and deploy your applications + - Perform code reviews and enhance collaboration with merge requests - - Each project can also have an issue tracker, issue board, and a wiki + - Complete continuous integration (CI) and CD pipelines to builds, test, and deploy your applications - - Used by more than 100,000 organizations, GitLab is the most popular solution to manage Git repositories on-premises + - Each project can also have an issue tracker, issue board, and a wiki - - Completely free and open source (MIT Expat license) - GFM - ) + - Used by more than 100,000 organizations, GitLab is the most popular solution to manage Git repositories on-premises - verify( - 'InlineDiffFilter', + - Completely free and open source (MIT Expat license) + GFM + ) - '{-Deleted text-}', - '{+Added text+}' - ) + verify( + 'InlineDiffFilter', - verify( - 'TaskListFilter', + '{-Deleted text-}', + '{+Added text+}' + ) - '- [ ] Unchecked task', - '- [x] Checked task', - '1. [ ] Unchecked numbered task', - '1. [x] Checked numbered task' - ) + verify( + 'TaskListFilter', - verify( - 'ReferenceFilter', + '- [ ] Unchecked task', + '- [x] Checked task', + '1. [ ] Unchecked numbered task', + '1. [x] Checked numbered task' + ) - # issue reference - @feat.issue.to_reference, - # full issue reference - @feat.issue.to_reference(full: true), - # issue URL - namespace_project_issue_url(@project.namespace, @project, @feat.issue), - # issue URL with note anchor - namespace_project_issue_url(@project.namespace, @project, @feat.issue, anchor: 'note_123'), - # issue link - "[Issue](#{namespace_project_issue_url(@project.namespace, @project, @feat.issue)})", - # issue link with note anchor - "[Issue](#{namespace_project_issue_url(@project.namespace, @project, @feat.issue, anchor: 'note_123')})", - ) + verify( + 'ReferenceFilter', - verify( - 'AutolinkFilter', + # issue reference + @feat.issue.to_reference, + # full issue reference + @feat.issue.to_reference(full: true), + # issue URL + namespace_project_issue_url(@project.namespace, @project, @feat.issue), + # issue URL with note anchor + namespace_project_issue_url(@project.namespace, @project, @feat.issue, anchor: 'note_123'), + # issue link + "[Issue](#{namespace_project_issue_url(@project.namespace, @project, @feat.issue)})", + # issue link with note anchor + "[Issue](#{namespace_project_issue_url(@project.namespace, @project, @feat.issue, anchor: 'note_123')})", + ) - 'https://example.com' - ) + verify( + 'AutolinkFilter', - verify( - 'TableOfContentsFilter', + 'https://example.com' + ) - '[[_TOC_]]' - ) + verify( + 'TableOfContentsFilter', - verify( - 'EmojiFilter', + '[[_TOC_]]' + ) - ':thumbsup:' - ) + verify( + 'EmojiFilter', - verify( - 'ImageLinkFilter', - - '![Image](https://example.com/image.png)' - ) + ':thumbsup:' + ) - verify( - 'VideoLinkFilter', + verify( + 'ImageLinkFilter', + + '![Image](https://example.com/image.png)' + ) - '![Video](https://example.com/video.mp4)' - ) + verify( + 'VideoLinkFilter', - verify( - 'MathFilter: math as converted from GFM to HTML', + '![Video](https://example.com/video.mp4)' + ) - '$`c = \pm\sqrt{a^2 + b^2}`$', + verify( + 'MathFilter: math as converted from GFM to HTML', - # math block - <<-GFM.strip_heredoc - ```math - c = \pm\sqrt{a^2 + b^2} - ``` - GFM - ) + '$`c = \pm\sqrt{a^2 + b^2}`$', - aggregate_failures('MathFilter: math as transformed from HTML to KaTeX') do - gfm = '$`c = \pm\sqrt{a^2 + b^2}`$' + # math block + <<-GFM.strip_heredoc + ```math + c = \pm\sqrt{a^2 + b^2} + ``` + GFM + ) - html = <<-HTML.strip_heredoc - - - - - - c - = - ± - - - - a - 2 - - + - - b - 2 - - - - - c = \\pm\\sqrt{a^2 + b^2} - - - -