From 8aa757aa532c14445aca8731b7a553e657a58593 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Fri, 10 Feb 2017 17:12:18 +0000 Subject: Formats timeago dates to be more friendly Formats the timeago timestamps to be a short date. This will only be visible on slower connections whilst the JS is loading, after the JS has loaded it will be turned into a timeago string Closes #27537 --- app/helpers/application_helper.rb | 2 +- changelogs/unreleased/format-timeago-date.yml | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 changelogs/unreleased/format-timeago-date.yml diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index bee323993a0..724641fd9d8 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -166,7 +166,7 @@ module ApplicationHelper css_classes = short_format ? 'js-short-timeago' : 'js-timeago' css_classes << " #{html_class}" unless html_class.blank? - element = content_tag :time, time.to_s, + element = content_tag :time, time.strftime("%b %d, %Y"), class: css_classes, title: time.to_time.in_time_zone.to_s(:medium), datetime: time.to_time.getutc.iso8601, diff --git a/changelogs/unreleased/format-timeago-date.yml b/changelogs/unreleased/format-timeago-date.yml new file mode 100644 index 00000000000..f331c34abbc --- /dev/null +++ b/changelogs/unreleased/format-timeago-date.yml @@ -0,0 +1,4 @@ +--- +title: Format timeago date to short format +merge_request: +author: -- cgit v1.2.1 From a9c80dceb688623e8f06a925caf629fddfa180ec Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Mon, 13 Feb 2017 09:01:09 +0000 Subject: Fixed timeago specs --- spec/features/commits_spec.rb | 2 +- spec/helpers/application_helper_spec.rb | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/spec/features/commits_spec.rb b/spec/features/commits_spec.rb index 8f561c8f90b..dede562c2b0 100644 --- a/spec/features/commits_spec.rb +++ b/spec/features/commits_spec.rb @@ -192,7 +192,7 @@ describe 'Commits' do commits = project.repository.commits(branch_name) commits.each do |commit| - expect(page).to have_content("committed #{commit.committed_date}") + expect(page).to have_content("committed #{commit.committed_date.strftime("%b %d, %Y")}") end end end diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb index 8b201f348f1..9749936a2a0 100644 --- a/spec/helpers/application_helper_spec.rb +++ b/spec/helpers/application_helper_spec.rb @@ -193,8 +193,8 @@ describe ApplicationHelper do describe 'time_ago_with_tooltip' do def element(*arguments) Time.zone = 'UTC' - time = Time.zone.parse('2015-07-02 08:23') - element = helper.time_ago_with_tooltip(time, *arguments) + @time = Time.zone.parse('2015-07-02 08:23') + element = helper.time_ago_with_tooltip(@time, *arguments) Nokogiri::HTML::DocumentFragment.parse(element).first_element_child end @@ -204,7 +204,7 @@ describe ApplicationHelper do end it 'includes the date string' do - expect(element.text).to eq '2015-07-02 08:23:00 UTC' + expect(element.text).to eq @time.strftime("%b %d, %Y") end it 'has a datetime attribute' do -- cgit v1.2.1 From 1ca5ab704eb086b6b09caf8abaabb373c58dbf09 Mon Sep 17 00:00:00 2001 From: Ben Bodenmiller Date: Thu, 23 Feb 2017 17:56:36 +0000 Subject: add Microsoft Exchange reply by email details fix #28131 [skip ci] --- doc/administration/reply_by_email.md | 56 ++++++++++++++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 3 deletions(-) diff --git a/doc/administration/reply_by_email.md b/doc/administration/reply_by_email.md index 00494e7e9d6..85b60f09b58 100644 --- a/doc/administration/reply_by_email.md +++ b/doc/administration/reply_by_email.md @@ -13,7 +13,8 @@ three strategies for this feature: ### Email sub-addressing -**If your provider or server supports email sub-addressing, we recommend using it.** +**If your provider or server supports email sub-addressing, we recommend using it. +Some features (e.g. create new issue via email) only work with sub-addressing.** [Sub-addressing](https://en.wikipedia.org/wiki/Email_address#Sub-addressing) is a feature where any email to `user+some_arbitrary_tag@example.com` will end up @@ -138,12 +139,32 @@ To set up a basic Postfix mail server with IMAP access on Ubuntu, follow the # The IDLE command timeout. gitlab_rails['incoming_email_idle_timeout'] = 60 ``` + + ```ruby + # Configuration for Microsoft Exchange mail server w/ IMAP enabled, assumes mailbox incoming@exchange.example.com + gitlab_rails['incoming_email_enabled'] = true + + # The email address replies are sent to - Exchange does not support sub-addressing so %{key} is not used here + gitlab_rails['incoming_email_address'] = "incoming@exchange.example.com" + + # Email account username + # Typically this is the userPrincipalName (UPN) + gitlab_rails['incoming_email_email'] = "incoming@ad-domain.example.com" + # Email account password + gitlab_rails['incoming_email_password'] = "[REDACTED]" + + # IMAP server host + gitlab_rails['incoming_email_host'] = "exchange.example.com" + # IMAP server port + gitlab_rails['incoming_email_port'] = 993 + # Whether the IMAP server uses SSL + gitlab_rails['incoming_email_ssl'] = true + ``` -1. Reconfigure GitLab and restart mailroom for the changes to take effect: +1. Reconfigure GitLab for the changes to take effect: ```sh sudo gitlab-ctl reconfigure - sudo gitlab-ctl restart mailroom ``` 1. Verify that everything is configured correctly: @@ -230,6 +251,35 @@ To set up a basic Postfix mail server with IMAP access on Ubuntu, follow the # The IDLE command timeout. idle_timeout: 60 ``` + + ```yaml + # Configuration for Microsoft Exchange mail server w/ IMAP enabled, assumes mailbox incoming@exchange.example.com + incoming_email: + enabled: true + + # The email address replies are sent to - Exchange does not support sub-addressing so %{key} is not used here + address: "incoming@exchange.example.com" + + # Email account username + # Typically this is the userPrincipalName (UPN) + user: "incoming@ad-domain.example.com" + # Email account password + password: "[REDACTED]" + + # IMAP server host + host: "exchange.example.com" + # IMAP server port + port: 993 + # Whether the IMAP server uses SSL + ssl: true + # Whether the IMAP server uses StartTLS + start_tls: false + + # The mailbox where incoming mail will end up. Usually "inbox". + mailbox: "inbox" + # The IDLE command timeout. + idle_timeout: 60 + ``` 1. Enable `mail_room` in the init script at `/etc/default/gitlab`: -- cgit v1.2.1 From 40c9a948b068e84873c8369d16d328790cab1cda Mon Sep 17 00:00:00 2001 From: Georg G Date: Sun, 26 Feb 2017 20:14:02 +0100 Subject: Change language for testing colour fallback --- spec/controllers/projects/graphs_controller_spec.rb | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/spec/controllers/projects/graphs_controller_spec.rb b/spec/controllers/projects/graphs_controller_spec.rb index c4a7aa7d63e..203b20667bf 100644 --- a/spec/controllers/projects/graphs_controller_spec.rb +++ b/spec/controllers/projects/graphs_controller_spec.rb @@ -14,18 +14,18 @@ describe Projects::GraphsController do double(languages: { 'Ruby' => 1000, 'CoffeeScript' => 350, - 'PowerShell' => 15 + 'NSIS' => 15 }) end let(:expected_values) do - ps_color = "##{Digest::SHA256.hexdigest('PowerShell')[0...6]}" + nsis_color = "##{Digest::SHA256.hexdigest('NSIS')[0...6]}" [ # colors from Linguist: - { label: "Ruby", color: "#701516", highlight: "#701516" }, - { label: "CoffeeScript", color: "#244776", highlight: "#244776" }, + { label: "Ruby", color: "#701516", highlight: "#701516" }, + { label: "CoffeeScript", color: "#244776", highlight: "#244776" }, # colors from SHA256 fallback: - { label: "PowerShell", color: ps_color, highlight: ps_color } + { label: "NSIS", color: nsis_color, highlight: nsis_color } ] end -- cgit v1.2.1 From 0a31efb57768345e2b3350a493021a26b54994a3 Mon Sep 17 00:00:00 2001 From: Adam Niedzielski Date: Tue, 7 Feb 2017 18:02:02 +0100 Subject: Remove query parameters from notes polling endpoint to make caching easier --- app/assets/javascripts/notes.js | 2 +- app/controllers/projects/notes_controller.rb | 7 +++++- .../projects/notes/_notes_with_form.html.haml | 2 +- config/routes/project.rb | 4 +++- spec/controllers/projects/notes_controller_spec.rb | 27 ++++++++++++++++++++++ spec/routing/project_routing_spec.rb | 18 +++++++++++---- 6 files changed, 52 insertions(+), 8 deletions(-) diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index 47fa0f2eb96..df7a7d2a459 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -198,7 +198,7 @@ require('./task_list'); this.refreshing = true; return $.ajax({ url: this.notes_url, - data: "last_fetched_at=" + this.last_fetched_at, + headers: { "X-Last-Fetched-At": this.last_fetched_at }, dataType: "json", success: (function(_this) { return function(data) { diff --git a/app/controllers/projects/notes_controller.rb b/app/controllers/projects/notes_controller.rb index 5cf3a7f593b..d00177e7612 100644 --- a/app/controllers/projects/notes_controller.rb +++ b/app/controllers/projects/notes_controller.rb @@ -211,6 +211,11 @@ class Projects::NotesController < Projects::ApplicationController end def find_current_user_notes - @notes = NotesFinder.new(project, current_user, params).execute.inc_author + @notes = NotesFinder.new(project, current_user, params.merge(last_fetched_at: last_fetched_at)) + .execute.inc_author + end + + def last_fetched_at + request.headers['X-Last-Fetched-At'] end end diff --git a/app/views/projects/notes/_notes_with_form.html.haml b/app/views/projects/notes/_notes_with_form.html.haml index 08c73d94a09..90a150aa74c 100644 --- a/app/views/projects/notes/_notes_with_form.html.haml +++ b/app/views/projects/notes/_notes_with_form.html.haml @@ -23,4 +23,4 @@ to post a comment :javascript - var notes = new Notes("#{namespace_project_notes_path(namespace_id: @project.namespace, project_id: @project, target_id: @noteable.id, target_type: @noteable.class.name.underscore)}", #{@notes.map(&:id).to_json}, #{Time.now.to_i}, "#{diff_view}") + var notes = new Notes("#{namespace_project_noteable_notes_path(namespace_id: @project.namespace, project_id: @project, target_id: @noteable.id, target_type: @noteable.class.name.underscore)}", #{@notes.map(&:id).to_json}, #{Time.now.to_i}, "#{diff_view}") diff --git a/config/routes/project.rb b/config/routes/project.rb index 84f123ff717..a9f95c09bef 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -265,7 +265,7 @@ constraints(ProjectUrlConstrainer.new) do resources :group_links, only: [:index, :create, :update, :destroy], constraints: { id: /\d+/ } - resources :notes, only: [:index, :create, :destroy, :update], concerns: :awardable, constraints: { id: /\d+/ } do + resources :notes, only: [:create, :destroy, :update], concerns: :awardable, constraints: { id: /\d+/ } do member do delete :delete_attachment post :resolve @@ -273,6 +273,8 @@ constraints(ProjectUrlConstrainer.new) do end end + get 'noteable/:target_type/:target_id/notes' => 'notes#index', as: 'noteable_notes' + resources :boards, only: [:index, :show] do scope module: :boards do resources :issues, only: [:index, :update] diff --git a/spec/controllers/projects/notes_controller_spec.rb b/spec/controllers/projects/notes_controller_spec.rb index dc597202050..d80780b1d90 100644 --- a/spec/controllers/projects/notes_controller_spec.rb +++ b/spec/controllers/projects/notes_controller_spec.rb @@ -200,4 +200,31 @@ describe Projects::NotesController do end end end + + describe 'GET index' do + let(:last_fetched_at) { '1487756246' } + let(:request_params) do + { + namespace_id: project.namespace, + project_id: project, + target_type: 'issue', + target_id: issue.id + } + end + + before do + sign_in(user) + project.team << [user, :developer] + end + + it 'passes last_fetched_at from headers to NotesFinder' do + request.headers['X-Last-Fetched-At'] = last_fetched_at + + expect(NotesFinder).to receive(:new) + .with(anything, anything, hash_including(last_fetched_at: last_fetched_at)) + .and_call_original + + get :index, request_params + end + end end diff --git a/spec/routing/project_routing_spec.rb b/spec/routing/project_routing_spec.rb index a5bc62ef6c2..d31f1bdfb7c 100644 --- a/spec/routing/project_routing_spec.rb +++ b/spec/routing/project_routing_spec.rb @@ -431,12 +431,22 @@ describe 'project routing' do end end - # project_notes GET /:project_id/notes(.:format) notes#index - # POST /:project_id/notes(.:format) notes#create - # project_note DELETE /:project_id/notes/:id(.:format) notes#destroy + # project_noteable_notes GET /:project_id/noteable/:target_type/:target_id/notes notes#index + # POST /:project_id/notes(.:format) notes#create + # project_note DELETE /:project_id/notes/:id(.:format) notes#destroy describe Projects::NotesController, 'routing' do + it 'to #index' do + expect(get('/gitlab/gitlabhq/noteable/issue/1/notes')).to route_to( + 'projects/notes#index', + namespace_id: 'gitlab', + project_id: 'gitlabhq', + target_type: 'issue', + target_id: '1' + ) + end + it_behaves_like 'RESTful project resources' do - let(:actions) { [:index, :create, :destroy] } + let(:actions) { [:create, :destroy] } let(:controller) { 'notes' } end end -- cgit v1.2.1 From 61c9604721dbda53eb4a7111d16c1b19292f9766 Mon Sep 17 00:00:00 2001 From: Adam Niedzielski Date: Tue, 7 Feb 2017 18:06:08 +0100 Subject: Add middleware for ETag caching with Redis --- changelogs/unreleased/etag-notes-polling.yml | 4 + config/initializers/etag_caching.rb | 4 + lib/gitlab/etag_caching/middleware.rb | 66 ++++++++++ lib/gitlab/etag_caching/store.rb | 32 +++++ spec/lib/gitlab/etag_caching/middleware_spec.rb | 163 ++++++++++++++++++++++++ 5 files changed, 269 insertions(+) create mode 100644 changelogs/unreleased/etag-notes-polling.yml create mode 100644 config/initializers/etag_caching.rb create mode 100644 lib/gitlab/etag_caching/middleware.rb create mode 100644 lib/gitlab/etag_caching/store.rb create mode 100644 spec/lib/gitlab/etag_caching/middleware_spec.rb diff --git a/changelogs/unreleased/etag-notes-polling.yml b/changelogs/unreleased/etag-notes-polling.yml new file mode 100644 index 00000000000..53990821d25 --- /dev/null +++ b/changelogs/unreleased/etag-notes-polling.yml @@ -0,0 +1,4 @@ +--- +title: Use ETag to improve performance of issue notes polling +merge_request: 9036 +author: diff --git a/config/initializers/etag_caching.rb b/config/initializers/etag_caching.rb new file mode 100644 index 00000000000..eba88801141 --- /dev/null +++ b/config/initializers/etag_caching.rb @@ -0,0 +1,4 @@ +# This middleware has to come after Gitlab::Metrics::RackMiddleware +# in the middleware stack, because it tracks events with +# GitLab Performance Monitoring +Rails.application.config.middleware.use(Gitlab::EtagCaching::Middleware) diff --git a/lib/gitlab/etag_caching/middleware.rb b/lib/gitlab/etag_caching/middleware.rb new file mode 100644 index 00000000000..0f24f9bbfde --- /dev/null +++ b/lib/gitlab/etag_caching/middleware.rb @@ -0,0 +1,66 @@ +module Gitlab + module EtagCaching + class Middleware + RESERVED_WORDS = ProjectPathValidator::RESERVED.map { |word| "/#{word}/" }.join('|') + ROUTE_REGEXP = Regexp.union( + %r(^(?!.*(#{RESERVED_WORDS})).*/noteable/issue/\d+/notes\z) + ) + + def initialize(app) + @app = app + end + + def call(env) + return @app.call(env) unless enabled_for_current_route?(env) + Gitlab::Metrics.add_event(:etag_caching_middleware_used) + + etag, cached_value_present = get_etag(env) + if_none_match = env['HTTP_IF_NONE_MATCH'] + + if if_none_match == etag + Gitlab::Metrics.add_event(:etag_caching_cache_hit) + [304, { 'ETag' => etag }, ['']] + else + track_cache_miss(if_none_match, cached_value_present) + + status, headers, body = @app.call(env) + headers['ETag'] = etag + [status, headers, body] + end + end + + private + + def enabled_for_current_route?(env) + ROUTE_REGEXP.match(env['PATH_INFO']) + end + + def get_etag(env) + cache_key = env['PATH_INFO'] + store = Store.new + current_value = store.get(cache_key) + cached_value_present = current_value.present? + + unless cached_value_present + current_value = store.touch(cache_key, only_if_missing: true) + end + + [weak_etag_format(current_value), cached_value_present] + end + + def weak_etag_format(value) + %Q{W/"#{value}"} + end + + def track_cache_miss(if_none_match, cached_value_present) + if if_none_match.blank? + Gitlab::Metrics.add_event(:etag_caching_header_missing) + elsif !cached_value_present + Gitlab::Metrics.add_event(:etag_caching_key_not_found) + else + Gitlab::Metrics.add_event(:etag_caching_resource_changed) + end + end + end + end +end diff --git a/lib/gitlab/etag_caching/store.rb b/lib/gitlab/etag_caching/store.rb new file mode 100644 index 00000000000..9532e432f78 --- /dev/null +++ b/lib/gitlab/etag_caching/store.rb @@ -0,0 +1,32 @@ +module Gitlab + module EtagCaching + class Store + EXPIRY_TIME = 10.minutes + REDIS_NAMESPACE = 'etag:'.freeze + + def get(key) + Gitlab::Redis.with { |redis| redis.get(redis_key(key)) } + end + + def touch(key, only_if_missing: false) + etag = generate_etag + + Gitlab::Redis.with do |redis| + redis.set(redis_key(key), etag, ex: EXPIRY_TIME, nx: only_if_missing) + end + + etag + end + + private + + def generate_etag + SecureRandom.hex + end + + def redis_key(key) + "#{REDIS_NAMESPACE}#{key}" + end + end + end +end diff --git a/spec/lib/gitlab/etag_caching/middleware_spec.rb b/spec/lib/gitlab/etag_caching/middleware_spec.rb new file mode 100644 index 00000000000..8b5bfc4dbb0 --- /dev/null +++ b/spec/lib/gitlab/etag_caching/middleware_spec.rb @@ -0,0 +1,163 @@ +require 'spec_helper' + +describe Gitlab::EtagCaching::Middleware do + let(:app) { double(:app) } + let(:middleware) { described_class.new(app) } + let(:app_status_code) { 200 } + let(:if_none_match) { nil } + let(:enabled_path) { '/gitlab-org/gitlab-ce/noteable/issue/1/notes' } + + context 'when ETag caching is not enabled for current route' do + let(:path) { '/gitlab-org/gitlab-ce/tree/master/noteable/issue/1/notes' } + + before do + mock_app_response + end + + it 'does not add ETag header' do + _, headers, _ = middleware.call(build_env(path, if_none_match)) + + expect(headers['ETag']).to be_nil + end + + it 'passes status code from app' do + status, _, _ = middleware.call(build_env(path, if_none_match)) + + expect(status).to eq app_status_code + end + end + + context 'when there is no ETag in store for given resource' do + let(:path) { enabled_path } + + before do + mock_app_response + mock_value_in_store(nil) + end + + it 'generates ETag' do + expect_any_instance_of(Gitlab::EtagCaching::Store) + .to receive(:touch).and_return('123') + + middleware.call(build_env(path, if_none_match)) + end + + context 'when If-None-Match header was specified' do + let(:if_none_match) { 'W/"abc"' } + + it 'tracks "etag_caching_key_not_found" event' do + expect(Gitlab::Metrics).to receive(:add_event) + .with(:etag_caching_middleware_used) + expect(Gitlab::Metrics).to receive(:add_event) + .with(:etag_caching_key_not_found) + + middleware.call(build_env(path, if_none_match)) + end + end + end + + context 'when there is ETag in store for given resource' do + let(:path) { enabled_path } + + before do + mock_app_response + mock_value_in_store('123') + end + + it 'returns this value as header' do + _, headers, _ = middleware.call(build_env(path, if_none_match)) + + expect(headers['ETag']).to eq 'W/"123"' + end + end + + context 'when If-None-Match header matches ETag in store' do + let(:path) { enabled_path } + let(:if_none_match) { 'W/"123"' } + + before do + mock_value_in_store('123') + end + + it 'does not call app' do + expect(app).not_to receive(:call) + + middleware.call(build_env(path, if_none_match)) + end + + it 'returns status code 304' do + status, _, _ = middleware.call(build_env(path, if_none_match)) + + expect(status).to eq 304 + end + + it 'tracks "etag_caching_cache_hit" event' do + expect(Gitlab::Metrics).to receive(:add_event) + .with(:etag_caching_middleware_used) + expect(Gitlab::Metrics).to receive(:add_event) + .with(:etag_caching_cache_hit) + + middleware.call(build_env(path, if_none_match)) + end + end + + context 'when If-None-Match header does not match ETag in store' do + let(:path) { enabled_path } + let(:if_none_match) { 'W/"abc"' } + + before do + mock_value_in_store('123') + end + + it 'calls app' do + expect(app).to receive(:call).and_return([app_status_code, {}, ['body']]) + + middleware.call(build_env(path, if_none_match)) + end + + it 'tracks "etag_caching_resource_changed" event' do + mock_app_response + + expect(Gitlab::Metrics).to receive(:add_event) + .with(:etag_caching_middleware_used) + expect(Gitlab::Metrics).to receive(:add_event) + .with(:etag_caching_resource_changed) + + middleware.call(build_env(path, if_none_match)) + end + end + + context 'when If-None-Match header is not specified' do + let(:path) { enabled_path } + + before do + mock_value_in_store('123') + mock_app_response + end + + it 'tracks "etag_caching_header_missing" event' do + expect(Gitlab::Metrics).to receive(:add_event) + .with(:etag_caching_middleware_used) + expect(Gitlab::Metrics).to receive(:add_event) + .with(:etag_caching_header_missing) + + middleware.call(build_env(path, if_none_match)) + end + end + + def mock_app_response + allow(app).to receive(:call).and_return([app_status_code, {}, ['body']]) + end + + def mock_value_in_store(value) + allow_any_instance_of(Gitlab::EtagCaching::Store) + .to receive(:get).and_return(value) + end + + def build_env(path, if_none_match) + { + 'PATH_INFO' => path, + 'HTTP_IF_NONE_MATCH' => if_none_match + } + end +end -- cgit v1.2.1 From c661df356156240deeef7c2734670ba5f2b4b04b Mon Sep 17 00:00:00 2001 From: Adam Niedzielski Date: Wed, 8 Feb 2017 18:04:16 +0100 Subject: Invalidate ETag cache when note changes --- app/models/note.rb | 13 +++++++++++++ spec/models/note_spec.rb | 12 ++++++++++++ 2 files changed, 25 insertions(+) diff --git a/app/models/note.rb b/app/models/note.rb index 4c97e4a986c..e22e96aec6f 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -85,6 +85,7 @@ class Note < ActiveRecord::Base before_validation :nullify_blank_type, :nullify_blank_line_code before_validation :set_discussion_id after_save :keep_around_commit, unless: :for_personal_snippet? + after_save :expire_etag_cache class << self def model_name @@ -272,4 +273,16 @@ class Note < ActiveRecord::Base self.class.build_discussion_id(noteable_type, noteable_id || commit_id) end end + + def expire_etag_cache + return unless for_issue? + + key = Gitlab::Routing.url_helpers.namespace_project_noteable_notes_path( + noteable.project.namespace, + noteable.project, + target_type: noteable_type.underscore, + target_id: noteable.id + ) + Gitlab::EtagCaching::Store.new.touch(key) + end end diff --git a/spec/models/note_spec.rb b/spec/models/note_spec.rb index 1cde9e04951..33536487c41 100644 --- a/spec/models/note_spec.rb +++ b/spec/models/note_spec.rb @@ -387,4 +387,16 @@ describe Note, models: true do end end end + + describe 'expiring ETag cache' do + let(:note) { build(:note_on_issue) } + + it "expires cache for note's issue when note is saved" do + expect_any_instance_of(Gitlab::EtagCaching::Store) + .to receive(:touch) + .with("/#{note.project.namespace.to_param}/#{note.project.to_param}/noteable/issue/#{note.noteable.id}/notes") + + note.save! + end + end end -- cgit v1.2.1 From ee318727774dbec75d5506a4b4749fc4236206d5 Mon Sep 17 00:00:00 2001 From: Adam Niedzielski Date: Wed, 1 Mar 2017 18:15:28 +0100 Subject: Execute metrics initializer earlier This makes sure that Gitlab::Metrics::RackMiddleware is added before Gitlab::EtagCaching::Middleware. --- config/initializers/8_metrics.rb | 188 ++++++++++++++++++++++++++++++++++++ config/initializers/metrics.rb | 188 ------------------------------------ doc/development/instrumentation.md | 2 +- spec/initializers/8_metrics_spec.rb | 16 +++ spec/initializers/metrics_spec.rb | 16 --- 5 files changed, 205 insertions(+), 205 deletions(-) create mode 100644 config/initializers/8_metrics.rb delete mode 100644 config/initializers/metrics.rb create mode 100644 spec/initializers/8_metrics_spec.rb delete mode 100644 spec/initializers/metrics_spec.rb diff --git a/config/initializers/8_metrics.rb b/config/initializers/8_metrics.rb new file mode 100644 index 00000000000..a1517e6afc8 --- /dev/null +++ b/config/initializers/8_metrics.rb @@ -0,0 +1,188 @@ +# Autoload all classes that we want to instrument, and instrument the methods we +# need. This takes the Gitlab::Metrics::Instrumentation module as an argument so +# that we can stub it for testing, as it is only called when metrics are +# enabled. +# +# rubocop:disable Metrics/AbcSize +def instrument_classes(instrumentation) + instrumentation.instrument_instance_methods(Gitlab::Shell) + + instrumentation.instrument_methods(Gitlab::Git) + + Gitlab::Git.constants.each do |name| + const = Gitlab::Git.const_get(name) + + next unless const.is_a?(Module) + + instrumentation.instrument_methods(const) + instrumentation.instrument_instance_methods(const) + end + + # 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'] + } + + paths_to_instrument.each do |(path, prefix)| + prefix = Rails.root.join(*prefix) + + Dir[Rails.root.join(*path + ['*.rb'])].each do |file_path| + path = Pathname.new(file_path).relative_path_from(prefix) + const = path.to_s.sub('.rb', '').camelize.constantize + + instrumentation.instrument_methods(const) + instrumentation.instrument_instance_methods(const) + end + end + + instrumentation.instrument_methods(Premailer::Adapter::Nokogiri) + instrumentation.instrument_instance_methods(Premailer::Adapter::Nokogiri) + + [ + :Blame, :Branch, :BranchCollection, :Blob, :Commit, :Diff, :Repository, + :Tag, :TagCollection, :Tree + ].each do |name| + const = Rugged.const_get(name) + + instrumentation.instrument_methods(const) + instrumentation.instrument_instance_methods(const) + end + + # Instruments all Banzai filters and reference parsers + { + Filter: Rails.root.join('lib', 'banzai', 'filter', '*.rb'), + ReferenceParser: Rails.root.join('lib', 'banzai', 'reference_parser', '*.rb') + }.each do |const_name, path| + Dir[path].each do |file| + klass = File.basename(file, File.extname(file)).camelize + const = Banzai.const_get(const_name).const_get(klass) + + instrumentation.instrument_methods(const) + instrumentation.instrument_instance_methods(const) + end + end + + instrumentation.instrument_methods(Banzai::Renderer) + instrumentation.instrument_methods(Banzai::Querying) + + instrumentation.instrument_instance_methods(Banzai::ObjectRenderer) + instrumentation.instrument_instance_methods(Banzai::Redactor) + instrumentation.instrument_methods(Banzai::NoteRenderer) + + [Issuable, Mentionable, Participable].each do |klass| + instrumentation.instrument_instance_methods(klass) + instrumentation.instrument_instance_methods(klass::ClassMethods) + end + + instrumentation.instrument_methods(Gitlab::ReferenceExtractor) + instrumentation.instrument_instance_methods(Gitlab::ReferenceExtractor) + + # Instrument the classes used for checking if somebody has push access. + instrumentation.instrument_instance_methods(Gitlab::GitAccess) + instrumentation.instrument_instance_methods(Gitlab::GitAccessWiki) + + instrumentation.instrument_instance_methods(API::Helpers) + + instrumentation.instrument_instance_methods(RepositoryCheck::SingleRepositoryWorker) + + instrumentation.instrument_instance_methods(Rouge::Plugins::Redcarpet) + instrumentation.instrument_instance_methods(Rouge::Formatters::HTMLGitlab) + + [:XML, :HTML].each do |namespace| + namespace_mod = Nokogiri.const_get(namespace) + + instrumentation.instrument_methods(namespace_mod) + instrumentation.instrument_methods(namespace_mod::Document) + end + + instrumentation.instrument_methods(Rinku) + instrumentation.instrument_instance_methods(Repository) + + instrumentation.instrument_methods(Gitlab::Highlight) + instrumentation.instrument_instance_methods(Gitlab::Highlight) + + # This is a Rails scope so we have to instrument it manually. + instrumentation.instrument_method(Project, :visible_to_user) +end +# rubocop:enable Metrics/AbcSize + +if Gitlab::Metrics.enabled? + require 'pathname' + require 'influxdb' + require 'connection_pool' + require 'method_source' + + # 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' + + Gitlab::Application.configure do |config| + config.middleware.use(Gitlab::Metrics::RackMiddleware) + config.middleware.use(Gitlab::Middleware::RailsQueueDuration) + end + + Sidekiq.configure_server do |config| + config.server_middleware do |chain| + chain.add Gitlab::Metrics::SidekiqMiddleware + end + end + + # This instruments all methods residing in app/models that (appear to) use any + # of the ActiveRecord methods. This has to take place _after_ initializing as + # for some unknown reason calling eager_load! earlier breaks Devise. + Gitlab::Application.config.after_initialize do + Rails.application.eager_load! + + models = Rails.root.join('app', 'models').to_s + + regex = Regexp.union( + ActiveRecord::Querying.public_instance_methods(false).map(&:to_s) + ) + + Gitlab::Metrics::Instrumentation. + instrument_class_hierarchy(ActiveRecord::Base) do |klass, method| + # Instrumenting the ApplicationSetting class can lead to an infinite + # loop. Since the data is cached any way we don't really need to + # instrument it. + if klass == ApplicationSetting + false + else + loc = method.source_location + + loc && loc[0].start_with?(models) && method.source =~ regex + end + end + end + + Gitlab::Metrics::Instrumentation.configure do |config| + instrument_classes(config) + end + + GC::Profiler.enable + + Gitlab::Metrics::Sampler.new.start + + module TrackNewRedisConnections + def connect(*args) + val = super + + if current_transaction = Gitlab::Metrics::Transaction.current + current_transaction.increment(:new_redis_connections, 1) + end + + val + end + end + + class ::Redis::Client + prepend TrackNewRedisConnections + end +end diff --git a/config/initializers/metrics.rb b/config/initializers/metrics.rb deleted file mode 100644 index a1517e6afc8..00000000000 --- a/config/initializers/metrics.rb +++ /dev/null @@ -1,188 +0,0 @@ -# Autoload all classes that we want to instrument, and instrument the methods we -# need. This takes the Gitlab::Metrics::Instrumentation module as an argument so -# that we can stub it for testing, as it is only called when metrics are -# enabled. -# -# rubocop:disable Metrics/AbcSize -def instrument_classes(instrumentation) - instrumentation.instrument_instance_methods(Gitlab::Shell) - - instrumentation.instrument_methods(Gitlab::Git) - - Gitlab::Git.constants.each do |name| - const = Gitlab::Git.const_get(name) - - next unless const.is_a?(Module) - - instrumentation.instrument_methods(const) - instrumentation.instrument_instance_methods(const) - end - - # 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'] - } - - paths_to_instrument.each do |(path, prefix)| - prefix = Rails.root.join(*prefix) - - Dir[Rails.root.join(*path + ['*.rb'])].each do |file_path| - path = Pathname.new(file_path).relative_path_from(prefix) - const = path.to_s.sub('.rb', '').camelize.constantize - - instrumentation.instrument_methods(const) - instrumentation.instrument_instance_methods(const) - end - end - - instrumentation.instrument_methods(Premailer::Adapter::Nokogiri) - instrumentation.instrument_instance_methods(Premailer::Adapter::Nokogiri) - - [ - :Blame, :Branch, :BranchCollection, :Blob, :Commit, :Diff, :Repository, - :Tag, :TagCollection, :Tree - ].each do |name| - const = Rugged.const_get(name) - - instrumentation.instrument_methods(const) - instrumentation.instrument_instance_methods(const) - end - - # Instruments all Banzai filters and reference parsers - { - Filter: Rails.root.join('lib', 'banzai', 'filter', '*.rb'), - ReferenceParser: Rails.root.join('lib', 'banzai', 'reference_parser', '*.rb') - }.each do |const_name, path| - Dir[path].each do |file| - klass = File.basename(file, File.extname(file)).camelize - const = Banzai.const_get(const_name).const_get(klass) - - instrumentation.instrument_methods(const) - instrumentation.instrument_instance_methods(const) - end - end - - instrumentation.instrument_methods(Banzai::Renderer) - instrumentation.instrument_methods(Banzai::Querying) - - instrumentation.instrument_instance_methods(Banzai::ObjectRenderer) - instrumentation.instrument_instance_methods(Banzai::Redactor) - instrumentation.instrument_methods(Banzai::NoteRenderer) - - [Issuable, Mentionable, Participable].each do |klass| - instrumentation.instrument_instance_methods(klass) - instrumentation.instrument_instance_methods(klass::ClassMethods) - end - - instrumentation.instrument_methods(Gitlab::ReferenceExtractor) - instrumentation.instrument_instance_methods(Gitlab::ReferenceExtractor) - - # Instrument the classes used for checking if somebody has push access. - instrumentation.instrument_instance_methods(Gitlab::GitAccess) - instrumentation.instrument_instance_methods(Gitlab::GitAccessWiki) - - instrumentation.instrument_instance_methods(API::Helpers) - - instrumentation.instrument_instance_methods(RepositoryCheck::SingleRepositoryWorker) - - instrumentation.instrument_instance_methods(Rouge::Plugins::Redcarpet) - instrumentation.instrument_instance_methods(Rouge::Formatters::HTMLGitlab) - - [:XML, :HTML].each do |namespace| - namespace_mod = Nokogiri.const_get(namespace) - - instrumentation.instrument_methods(namespace_mod) - instrumentation.instrument_methods(namespace_mod::Document) - end - - instrumentation.instrument_methods(Rinku) - instrumentation.instrument_instance_methods(Repository) - - instrumentation.instrument_methods(Gitlab::Highlight) - instrumentation.instrument_instance_methods(Gitlab::Highlight) - - # This is a Rails scope so we have to instrument it manually. - instrumentation.instrument_method(Project, :visible_to_user) -end -# rubocop:enable Metrics/AbcSize - -if Gitlab::Metrics.enabled? - require 'pathname' - require 'influxdb' - require 'connection_pool' - require 'method_source' - - # 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' - - Gitlab::Application.configure do |config| - config.middleware.use(Gitlab::Metrics::RackMiddleware) - config.middleware.use(Gitlab::Middleware::RailsQueueDuration) - end - - Sidekiq.configure_server do |config| - config.server_middleware do |chain| - chain.add Gitlab::Metrics::SidekiqMiddleware - end - end - - # This instruments all methods residing in app/models that (appear to) use any - # of the ActiveRecord methods. This has to take place _after_ initializing as - # for some unknown reason calling eager_load! earlier breaks Devise. - Gitlab::Application.config.after_initialize do - Rails.application.eager_load! - - models = Rails.root.join('app', 'models').to_s - - regex = Regexp.union( - ActiveRecord::Querying.public_instance_methods(false).map(&:to_s) - ) - - Gitlab::Metrics::Instrumentation. - instrument_class_hierarchy(ActiveRecord::Base) do |klass, method| - # Instrumenting the ApplicationSetting class can lead to an infinite - # loop. Since the data is cached any way we don't really need to - # instrument it. - if klass == ApplicationSetting - false - else - loc = method.source_location - - loc && loc[0].start_with?(models) && method.source =~ regex - end - end - end - - Gitlab::Metrics::Instrumentation.configure do |config| - instrument_classes(config) - end - - GC::Profiler.enable - - Gitlab::Metrics::Sampler.new.start - - module TrackNewRedisConnections - def connect(*args) - val = super - - if current_transaction = Gitlab::Metrics::Transaction.current - current_transaction.increment(:new_redis_connections, 1) - end - - val - end - end - - class ::Redis::Client - prepend TrackNewRedisConnections - end -end diff --git a/doc/development/instrumentation.md b/doc/development/instrumentation.md index b8669964c84..a14c0752366 100644 --- a/doc/development/instrumentation.md +++ b/doc/development/instrumentation.md @@ -35,7 +35,7 @@ Using this method is in general preferred over directly calling the various instrumentation methods. Method instrumentation should be added in the initializer -`config/initializers/metrics.rb`. +`config/initializers/8_metrics.rb`. ### Examples diff --git a/spec/initializers/8_metrics_spec.rb b/spec/initializers/8_metrics_spec.rb new file mode 100644 index 00000000000..570754621f3 --- /dev/null +++ b/spec/initializers/8_metrics_spec.rb @@ -0,0 +1,16 @@ +require 'spec_helper' +require_relative '../../config/initializers/8_metrics' + +describe 'instrument_classes', lib: true do + let(:config) { double(:config) } + + before do + allow(config).to receive(:instrument_method) + allow(config).to receive(:instrument_methods) + allow(config).to receive(:instrument_instance_methods) + end + + it 'can autoload and instrument all files' do + expect { instrument_classes(config) }.not_to raise_error + end +end diff --git a/spec/initializers/metrics_spec.rb b/spec/initializers/metrics_spec.rb deleted file mode 100644 index bb595162370..00000000000 --- a/spec/initializers/metrics_spec.rb +++ /dev/null @@ -1,16 +0,0 @@ -require 'spec_helper' -require_relative '../../config/initializers/metrics' - -describe 'instrument_classes', lib: true do - let(:config) { double(:config) } - - before do - allow(config).to receive(:instrument_method) - allow(config).to receive(:instrument_methods) - allow(config).to receive(:instrument_instance_methods) - end - - it 'can autoload and instrument all files' do - expect { instrument_classes(config) }.not_to raise_error - end -end -- cgit v1.2.1 From 4c3412c37586b624585732326714a605bb226ba9 Mon Sep 17 00:00:00 2001 From: Mark Fletcher Date: Tue, 28 Feb 2017 22:44:57 +0530 Subject: Update profiles/account view to display new username --- app/controllers/profiles_controller.rb | 11 +++++++---- app/views/profiles/accounts/show.html.haml | 2 +- app/views/profiles/update_username.js.haml | 7 ------- ...-text-is-not-updated-after-setting-the-new-username.yml | 4 ++++ spec/features/profile_spec.rb | 14 ++++++++++++++ 5 files changed, 26 insertions(+), 12 deletions(-) delete mode 100644 app/views/profiles/update_username.js.haml create mode 100644 changelogs/unreleased/28655-current-path-text-is-not-updated-after-setting-the-new-username.yml diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb index f0c71725ea8..987b95e89b9 100644 --- a/app/controllers/profiles_controller.rb +++ b/app/controllers/profiles_controller.rb @@ -47,11 +47,14 @@ class ProfilesController < Profiles::ApplicationController end def update_username - @user.update_attributes(username: user_params[:username]) - - respond_to do |format| - format.js + if @user.update_attributes(username: user_params[:username]) + options = { notice: "Username successfully changed" } + else + message = @user.errors.full_messages.uniq.join('. ') + options = { alert: "Username change failed - #{message}" } end + + redirect_back_or_default(default: { action: 'show' }, options: options) end private diff --git a/app/views/profiles/accounts/show.html.haml b/app/views/profiles/accounts/show.html.haml index 02fb47ec981..8a994f6d600 100644 --- a/app/views/profiles/accounts/show.html.haml +++ b/app/views/profiles/accounts/show.html.haml @@ -93,7 +93,7 @@ %p Changing your username will change path to all personal projects! .col-lg-9 - = form_for @user, url: update_username_profile_path, method: :put, remote: true, html: {class: "update-username"} do |f| + = form_for @user, url: update_username_profile_path, method: :put, html: {class: "update-username"} do |f| .form-group = f.label :username, "Path", class: "label-light" .input-group diff --git a/app/views/profiles/update_username.js.haml b/app/views/profiles/update_username.js.haml deleted file mode 100644 index 5307e0b48cb..00000000000 --- a/app/views/profiles/update_username.js.haml +++ /dev/null @@ -1,7 +0,0 @@ -- if @user.valid? - :plain - new Flash("Username successfully changed", "notice") -- else - - error = @user.errors.full_messages.first - :plain - new Flash("Username change failed - #{escape_javascript error.html_safe}", "alert") diff --git a/changelogs/unreleased/28655-current-path-text-is-not-updated-after-setting-the-new-username.yml b/changelogs/unreleased/28655-current-path-text-is-not-updated-after-setting-the-new-username.yml new file mode 100644 index 00000000000..bff996172f3 --- /dev/null +++ b/changelogs/unreleased/28655-current-path-text-is-not-updated-after-setting-the-new-username.yml @@ -0,0 +1,4 @@ +--- +title: Update account view to display new username +merge_request: +author: diff --git a/spec/features/profile_spec.rb b/spec/features/profile_spec.rb index 406d7cf791c..e63feb14b7e 100644 --- a/spec/features/profile_spec.rb +++ b/spec/features/profile_spec.rb @@ -61,4 +61,18 @@ describe 'Profile account page', feature: true do expect(find('#incoming-email-token').value).not_to eq(previous_token) end end + + describe 'when I change my username' do + before do + visit profile_account_path + end + + it 'changes my username' do + fill_in 'user_username', with: 'new-username' + + click_button('Update username') + + expect(page).to have_content('new-username') + end + end end -- cgit v1.2.1 From 9cc7d93641fd261c1d337aeebad7dca69a779b6c Mon Sep 17 00:00:00 2001 From: George Andrinopoulos Date: Wed, 8 Feb 2017 22:57:23 +0200 Subject: Hide issue info when project issues are disabled --- app/views/projects/milestones/show.html.haml | 2 +- app/views/shared/milestones/_summary.html.haml | 27 +++++---- app/views/shared/milestones/_tabs.html.haml | 34 ++++++++---- ...evant-info-when-project-issues-are-disabled.yml | 4 ++ .../features/projects/milestones/milestone_spec.rb | 64 ++++++++++++++++++++++ 5 files changed, 106 insertions(+), 25 deletions(-) create mode 100644 changelogs/unreleased/19497-hide-relevant-info-when-project-issues-are-disabled.yml create mode 100644 spec/features/projects/milestones/milestone_spec.rb diff --git a/app/views/projects/milestones/show.html.haml b/app/views/projects/milestones/show.html.haml index 06a31698ee6..212ca61a095 100644 --- a/app/views/projects/milestones/show.html.haml +++ b/app/views/projects/milestones/show.html.haml @@ -47,7 +47,7 @@ = preserve do = markdown_field(@milestone, :description) - - if @milestone.total_items_count(current_user).zero? + - if can?(current_user, :read_issue, @project) && @milestone.total_items_count(current_user).zero? .alert.alert-success.prepend-top-default %span Assign some issues to this milestone. - elsif @milestone.complete?(current_user) && @milestone.active? diff --git a/app/views/shared/milestones/_summary.html.haml b/app/views/shared/milestones/_summary.html.haml index d27fba805a3..78079f633d5 100644 --- a/app/views/shared/milestones/_summary.html.haml +++ b/app/views/shared/milestones/_summary.html.haml @@ -6,14 +6,15 @@ .milestone-stats-and-buttons .milestone-stats - %span.milestone-stat.with-drilldown - %strong= milestone.issues_visible_to_user(current_user).size - issues: - %span.milestone-stat - %strong= milestone.issues_visible_to_user(current_user).opened.size - open and - %strong= milestone.issues_visible_to_user(current_user).closed.size - closed + - if !project || can?(current_user, :read_issue, project) + %span.milestone-stat.with-drilldown + %strong= milestone.issues_visible_to_user(current_user).size + issues: + %span.milestone-stat + %strong= milestone.issues_visible_to_user(current_user).opened.size + open and + %strong= milestone.issues_visible_to_user(current_user).closed.size + closed %span.milestone-stat.with-drilldown %strong= milestone.merge_requests.size merge requests: @@ -32,10 +33,12 @@ .milestone-progress-buttons %span.tab-issues-buttons - - if project && can?(current_user, :create_issue, project) - = link_to new_namespace_project_issue_path(project.namespace, project, issue: { milestone_id: milestone.id }), class: "btn", title: "New Issue" do - New Issue - = link_to 'Browse Issues', milestones_browse_issuables_path(milestone, type: :issues), class: "btn" + - if project + - if can?(current_user, :create_issue, project) + = link_to new_namespace_project_issue_path(project.namespace, project, issue: { milestone_id: milestone.id }), class: "btn", title: "New Issue" do + New Issue + - if can?(current_user, :read_issue, project) + = link_to 'Browse Issues', milestones_browse_issuables_path(milestone, type: :issues), class: "btn" %span.tab-merge-requests-buttons.hidden = link_to 'Browse Merge Requests', milestones_browse_issuables_path(milestone, type: :merge_requests), class: "btn" diff --git a/app/views/shared/milestones/_tabs.html.haml b/app/views/shared/milestones/_tabs.html.haml index c8f2319d95a..a0e9ec46220 100644 --- a/app/views/shared/milestones/_tabs.html.haml +++ b/app/views/shared/milestones/_tabs.html.haml @@ -1,12 +1,18 @@ %ul.nav-links.no-top.no-bottom - %li.active - = link_to '#tab-issues', 'data-toggle' => 'tab', 'data-show' => '.tab-issues-buttons' do - Issues - %span.badge= milestone.issues_visible_to_user(current_user).size - %li - = link_to '#tab-merge-requests', 'data-toggle' => 'tab', 'data-show' => '.tab-merge-requests-buttons' do - Merge Requests - %span.badge= milestone.merge_requests.size + - if milestone.is_a?(GlobalMilestone) || can?(current_user, :read_issue, @project) + %li.active + = link_to '#tab-issues', 'data-toggle' => 'tab', 'data-show' => '.tab-issues-buttons' do + Issues + %span.badge= milestone.issues_visible_to_user(current_user).size + %li + = link_to '#tab-merge-requests', 'data-toggle' => 'tab', 'data-show' => '.tab-merge-requests-buttons' do + Merge Requests + %span.badge= milestone.merge_requests.size + - else + %li.active + = link_to '#tab-merge-requests', 'data-toggle' => 'tab', 'data-show' => '.tab-merge-requests-buttons' do + Merge Requests + %span.badge= milestone.merge_requests.size %li = link_to '#tab-participants', 'data-toggle' => 'tab' do Participants @@ -20,10 +26,14 @@ - show_full_project_name = local_assigns.fetch(:show_full_project_name, false) .tab-content.milestone-content - .tab-pane.active#tab-issues - = render 'shared/milestones/issues_tab', issues: milestone.issues_visible_to_user(current_user).include_associations, show_project_name: show_project_name, show_full_project_name: show_full_project_name - .tab-pane#tab-merge-requests - = render 'shared/milestones/merge_requests_tab', merge_requests: milestone.merge_requests, show_project_name: show_project_name, show_full_project_name: show_full_project_name + - if milestone.is_a?(GlobalMilestone) || can?(current_user, :read_issue, @project) + .tab-pane.active#tab-issues + = render 'shared/milestones/issues_tab', issues: milestone.issues_visible_to_user(current_user).include_associations, show_project_name: show_project_name, show_full_project_name: show_full_project_name + .tab-pane#tab-merge-requests + = render 'shared/milestones/merge_requests_tab', merge_requests: milestone.merge_requests, show_project_name: show_project_name, show_full_project_name: show_full_project_name + - else + .tab-pane.active#tab-merge-requests + = render 'shared/milestones/merge_requests_tab', merge_requests: milestone.merge_requests, show_project_name: show_project_name, show_full_project_name: show_full_project_name .tab-pane#tab-participants = render 'shared/milestones/participants_tab', users: milestone.participants .tab-pane#tab-labels diff --git a/changelogs/unreleased/19497-hide-relevant-info-when-project-issues-are-disabled.yml b/changelogs/unreleased/19497-hide-relevant-info-when-project-issues-are-disabled.yml new file mode 100644 index 00000000000..eceb2b9fac6 --- /dev/null +++ b/changelogs/unreleased/19497-hide-relevant-info-when-project-issues-are-disabled.yml @@ -0,0 +1,4 @@ +--- +title: Hide issue info when project issues are disabled +merge_request: +author: George Andrinopoulos diff --git a/spec/features/projects/milestones/milestone_spec.rb b/spec/features/projects/milestones/milestone_spec.rb new file mode 100644 index 00000000000..df229d0aa78 --- /dev/null +++ b/spec/features/projects/milestones/milestone_spec.rb @@ -0,0 +1,64 @@ +require 'spec_helper' + +feature 'Project milestone', :feature do + let(:user) { create(:user) } + let(:project) { create(:empty_project, name: 'test', namespace: user.namespace) } + let(:milestone) { create(:milestone, project: project) } + + before do + login_as(user) + end + + context 'when project has enabled issues' do + before do + visit namespace_project_milestone_path(project.namespace, project, milestone) + end + + it 'shows issues tab' do + within('#content-body') do + expect(page).to have_link 'Issues', href: '#tab-issues' + expect(page).to have_selector '.nav-links li.active', count: 1 + expect(find('.nav-links li.active')).to have_content 'Issues' + end + end + + it 'shows issues stats' do + expect(page).to have_content 'issues:' + end + + it 'shows Browse Issues button' do + within('#content-body') do + expect(page).to have_link 'Browse Issues' + end + end + end + + context 'when project has disabled issues' do + before do + project.project_feature.update_attribute(:issues_access_level, ProjectFeature::DISABLED) + visit namespace_project_milestone_path(project.namespace, project, milestone) + end + + it 'hides issues tab' do + within('#content-body') do + expect(page).not_to have_link 'Issues', href: '#tab-issues' + expect(page).to have_selector '.nav-links li.active', count: 1 + expect(find('.nav-links li.active')).to have_content 'Merge Requests' + end + end + + it 'hides issues stats' do + expect(page).to have_no_content 'issues:' + end + + it 'hides Browse Issues button' do + within('#content-body') do + expect(page).not_to have_link 'Browse Issues' + end + end + + it 'does not show an informative message' do + expect(page).not_to have_content('Assign some issues to this milestone.') + end + end +end -- cgit v1.2.1 From 2968e87aa7062db2c23265b2d22ac83b889a76d7 Mon Sep 17 00:00:00 2001 From: Felipe Artur Date: Thu, 2 Mar 2017 14:57:46 -0300 Subject: Revert "Disallow system notes for closed issuables" This reverts commit 0771774480ce1f46b27b629d3721917d6c1f1267. --- app/services/system_note_service.rb | 1 - spec/services/system_note_service_spec.rb | 39 ------------------------------- 2 files changed, 40 deletions(-) diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index db6a092d8fc..8e02fe3741a 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -385,7 +385,6 @@ module SystemNoteService # Returns Boolean def cross_reference_disallowed?(noteable, mentioner) return true if noteable.is_a?(ExternalIssue) && !noteable.project.jira_tracker_active? - return true if noteable.is_a?(Issuable) && (noteable.try(:closed?) || noteable.try(:merged?)) return false unless mentioner.is_a?(MergeRequest) return false unless noteable.is_a?(Commit) diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb index 1f2ec9eacf0..36a17a3bf2e 100644 --- a/spec/services/system_note_service_spec.rb +++ b/spec/services/system_note_service_spec.rb @@ -418,45 +418,6 @@ describe SystemNoteService, services: true do to be_truthy end end - - context 'when noteable is an Issue' do - let(:issue) { create(:issue, project: project) } - - it 'is truthy when issue is closed' do - issue.close - - expect(described_class.cross_reference_disallowed?(issue, project.commit)). - to be_truthy - end - - it 'is falsey when issue is open' do - expect(described_class.cross_reference_disallowed?(issue, project.commit)). - to be_falsy - end - end - - context 'when noteable is a Merge Request' do - let(:merge_request) { create(:merge_request, :simple, source_project: project) } - - it 'is truthy when merge request is closed' do - allow(merge_request).to receive(:closed?).and_return(:true) - - expect(described_class.cross_reference_disallowed?(merge_request, project.commit)). - to be_truthy - end - - it 'is truthy when merge request is merged' do - allow(merge_request).to receive(:closed?).and_return(:true) - - expect(described_class.cross_reference_disallowed?(merge_request, project.commit)). - to be_truthy - end - - it 'is falsey when merge request is open' do - expect(described_class.cross_reference_disallowed?(merge_request, project.commit)). - to be_falsy - end - end end describe '.cross_reference_exists?' do -- cgit v1.2.1 From 6cc4cf1e151fb8da16796d7bbab16bc8a1ac08b6 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Thu, 16 Feb 2017 18:24:56 -0600 Subject: Fix cherry-picking or reverting through an MR --- app/controllers/concerns/creates_commit.rb | 57 +++++++++------------- app/controllers/projects/blob_controller.rb | 6 +-- app/controllers/projects/commit_controller.rb | 32 +++++++----- app/models/repository.rb | 32 ++++++------ app/services/commits/change_service.rb | 52 +++++++++----------- app/views/projects/commit/_change.html.haml | 10 ++-- changelogs/unreleased/dm-fix-cherry-pick.yml | 4 ++ features/steps/project/commits/revert.rb | 1 + lib/api/commits.rb | 2 +- lib/api/v3/commits.rb | 4 +- .../controllers/projects/commit_controller_spec.rb | 12 ++--- spec/features/projects/commit/cherry_pick_spec.rb | 1 + spec/models/repository_spec.rb | 16 +++--- 13 files changed, 112 insertions(+), 117 deletions(-) create mode 100644 changelogs/unreleased/dm-fix-cherry-pick.yml diff --git a/app/controllers/concerns/creates_commit.rb b/app/controllers/concerns/creates_commit.rb index f897f828cec..9ac8197e45a 100644 --- a/app/controllers/concerns/creates_commit.rb +++ b/app/controllers/concerns/creates_commit.rb @@ -4,10 +4,9 @@ module CreatesCommit def create_commit(service, success_path:, failure_path:, failure_view: nil, success_notice: nil) set_commit_variables - start_branch = @mr_target_branch commit_params = @commit_params.merge( start_project: @mr_target_project, - start_branch: start_branch, + start_branch: @mr_target_branch, target_branch: @mr_source_branch ) @@ -17,12 +16,16 @@ module CreatesCommit if result[:status] == :success update_flash_notice(success_notice) + success_path = final_success_path(success_path) + respond_to do |format| - format.html { redirect_to final_success_path(success_path) } - format.json { render json: { message: "success", filePath: final_success_path(success_path) } } + format.html { redirect_to success_path } + format.json { render json: { message: "success", filePath: success_path } } end else flash[:alert] = result[:message] + failure_path = failure_path.call if failure_path.respond_to?(:call) + respond_to do |format| format.html do if failure_view @@ -58,9 +61,13 @@ module CreatesCommit end def final_success_path(success_path) - return success_path unless create_merge_request? + if create_merge_request? + merge_request_exists? ? existing_merge_request_path : new_merge_request_path + else + success_path = success_path.call if success_path.respond_to?(:call) - merge_request_exists? ? existing_merge_request_path : new_merge_request_path + success_path + end end def new_merge_request_path @@ -92,42 +99,26 @@ module CreatesCommit end def create_merge_request? - # XXX: Even if the field is set, if we're checking the same branch + # Even if the field is set, if we're checking the same branch # as the target branch in the same project, # we don't want to create a merge request. params[:create_merge_request].present? && - (different_project? || @ref != @target_branch) + (different_project? || @mr_target_branch != @mr_source_branch) end - # TODO: We should really clean this up def set_commit_variables - @mr_source_project = - if can?(current_user, :push_code, @project) - # Edit file in this project - @project - else - # Merge request from fork to this project - current_user.fork_of(@project) - end + if can?(current_user, :push_code, @project) + @mr_source_project = @project + @target_branch ||= @ref + else + @mr_source_project = current_user.fork_of(@project) + @target_branch ||= @mr_source_project.repository.next_branch('patch') + end # Merge request to this project @mr_target_project = @project - @mr_target_branch = @ref || @target_branch - - @mr_source_branch = guess_mr_source_branch - end + @mr_target_branch ||= @ref || @target_branch - def guess_mr_source_branch - # XXX: Happens when viewing a commit without a branch. In this case, - # @target_branch would be the default branch for @mr_source_project, - # however we want a generated new branch here. Thus we can't use - # @target_branch, but should pass nil to indicate that we want a new - # branch instead of @target_branch. - return if - create_merge_request? && - # XXX: Don't understand why rubocop prefers this indention - @mr_source_project.repository.branch_exists?(@target_branch) - - @target_branch + @mr_source_branch = @target_branch end end diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index f9a5ef46786..21ed0660762 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -24,7 +24,7 @@ class Projects::BlobController < Projects::ApplicationController def create create_commit(Files::CreateService, success_notice: "The file has been successfully created.", - success_path: namespace_project_blob_path(@project.namespace, @project, File.join(@target_branch, @file_path)), + success_path: -> { namespace_project_blob_path(@project.namespace, @project, File.join(@target_branch, @file_path)) }, failure_view: :new, failure_path: namespace_project_new_blob_path(@project.namespace, @project, @ref)) end @@ -40,7 +40,7 @@ class Projects::BlobController < Projects::ApplicationController def update @path = params[:file_path] if params[:file_path].present? - create_commit(Files::UpdateService, success_path: after_edit_path, + create_commit(Files::UpdateService, success_path: -> { after_edit_path }, failure_view: :edit, failure_path: namespace_project_blob_path(@project.namespace, @project, @id)) @@ -62,7 +62,7 @@ class Projects::BlobController < Projects::ApplicationController def destroy create_commit(Files::DestroyService, success_notice: "The file has been successfully deleted.", - success_path: namespace_project_tree_path(@project.namespace, @project, @target_branch), + success_path: -> { namespace_project_tree_path(@project.namespace, @project, @target_branch) }, failure_view: :show, failure_path: namespace_project_blob_path(@project.namespace, @project, @id)) end diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb index e10d7992db7..cc67f688d51 100644 --- a/app/controllers/projects/commit_controller.rb +++ b/app/controllers/projects/commit_controller.rb @@ -51,23 +51,35 @@ class Projects::CommitController < Projects::ApplicationController def revert assign_change_commit_vars - return render_404 if @target_branch.blank? + return render_404 if @start_branch.blank? + + @target_branch = create_new_branch? ? @commit.revert_branch_name : @start_branch + + @mr_target_branch = @start_branch create_commit(Commits::RevertService, success_notice: "The #{@commit.change_type_title(current_user)} has been successfully reverted.", - success_path: successful_change_path, failure_path: failed_change_path) + success_path: -> { successful_change_path }, failure_path: failed_change_path) end def cherry_pick assign_change_commit_vars - return render_404 if @target_branch.blank? + return render_404 if @start_branch.blank? + + @target_branch = create_new_branch? ? @commit.cherry_pick_branch_name : @start_branch + + @mr_target_branch = @start_branch create_commit(Commits::CherryPickService, success_notice: "The #{@commit.change_type_title(current_user)} has been successfully cherry-picked.", - success_path: successful_change_path, failure_path: failed_change_path) + success_path: -> { successful_change_path }, failure_path: failed_change_path) end private + def create_new_branch? + params[:create_merge_request].present? || !can?(current_user, :push_code, @project) + end + def successful_change_path referenced_merge_request_url || namespace_project_commits_url(@project.namespace, @project, @target_branch) end @@ -78,7 +90,7 @@ class Projects::CommitController < Projects::ApplicationController def referenced_merge_request_url if merge_request = @commit.merged_merge_request(current_user) - namespace_project_merge_request_url(@project.namespace, @project, merge_request) + namespace_project_merge_request_url(merge_request.target_project.namespace, merge_request.target_project, merge_request) end end @@ -94,7 +106,7 @@ class Projects::CommitController < Projects::ApplicationController @diffs = commit.diffs(opts) @notes_count = commit.notes.count - + @environment = EnvironmentsFinder.new(@project, current_user, commit: @commit).execute.last end @@ -118,11 +130,7 @@ class Projects::CommitController < Projects::ApplicationController end def assign_change_commit_vars - @commit = project.commit(params[:id]) - @target_branch = params[:target_branch] - @commit_params = { - commit: @commit, - create_merge_request: params[:create_merge_request].present? || different_project? - } + @start_branch = params[:start_branch] + @commit_params = { commit: @commit } end end diff --git a/app/models/repository.rb b/app/models/repository.rb index d410d60dbad..4f8b436ee16 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -862,17 +862,18 @@ class Repository end def revert( - user, commit, branch_name, revert_tree_id = nil, + user, commit, branch_name, start_branch_name: nil, start_project: project) - revert_tree_id ||= check_revert_content(commit, branch_name) - - return false unless revert_tree_id - GitOperationService.new(user, self).with_branch( branch_name, start_branch_name: start_branch_name, start_project: start_project) do |start_commit| + revert_tree_id = check_revert_content(commit, start_commit.sha) + unless revert_tree_id + raise Repository::CommitError.new('Failed to revert commit') + end + committer = user_to_committer(user) Rugged::Commit.create(rugged, @@ -885,17 +886,18 @@ class Repository end def cherry_pick( - user, commit, branch_name, cherry_pick_tree_id = nil, + user, commit, branch_name, start_branch_name: nil, start_project: project) - cherry_pick_tree_id ||= check_cherry_pick_content(commit, branch_name) - - return false unless cherry_pick_tree_id - GitOperationService.new(user, self).with_branch( branch_name, start_branch_name: start_branch_name, start_project: start_project) do |start_commit| + cherry_pick_tree_id = check_cherry_pick_content(commit, start_commit.sha) + unless cherry_pick_tree_id + raise Repository::CommitError.new('Failed to cherry-pick commit') + end + committer = user_to_committer(user) Rugged::Commit.create(rugged, @@ -919,9 +921,8 @@ class Repository end end - def check_revert_content(target_commit, branch_name) - source_sha = commit(branch_name).sha - args = [target_commit.sha, source_sha] + def check_revert_content(target_commit, source_sha) + args = [target_commit.sha, source_sha] args << { mainline: 1 } if target_commit.merge_commit? revert_index = rugged.revert_commit(*args) @@ -933,9 +934,8 @@ class Repository tree_id end - def check_cherry_pick_content(target_commit, branch_name) - source_sha = commit(branch_name).sha - args = [target_commit.sha, source_sha] + def check_cherry_pick_content(target_commit, source_sha) + args = [target_commit.sha, source_sha] args << 1 if target_commit.merge_commit? cherry_pick_index = rugged.cherrypick_commit(*args) diff --git a/app/services/commits/change_service.rb b/app/services/commits/change_service.rb index 8a9bcd2d053..3051ebf5e52 100644 --- a/app/services/commits/change_service.rb +++ b/app/services/commits/change_service.rb @@ -8,9 +8,9 @@ module Commits @start_branch = params[:start_branch] @target_branch = params[:target_branch] @commit = params[:commit] - @create_merge_request = params[:create_merge_request].present? - check_push_permissions unless @create_merge_request + check_push_permissions + commit rescue Repository::CommitError, Gitlab::Git::Repository::InvalidBlobName, GitHooksService::PreReceiveError, ValidationError, ChangeError => ex @@ -26,33 +26,24 @@ module Commits def commit_change(action) raise NotImplementedError unless repository.respond_to?(action) - if @create_merge_request - into = @commit.public_send("#{action}_branch_name") - tree_branch = @start_branch - else - into = tree_branch = @target_branch - end - - tree_id = repository.public_send( - "check_#{action}_content", @commit, tree_branch) - - if tree_id - validate_target_branch(into) if @create_merge_request + validate_target_branch if different_branch? - repository.public_send( - action, - current_user, - @commit, - into, - tree_id, - start_project: @start_project, - start_branch_name: @start_branch) + repository.public_send( + action, + current_user, + @commit, + @target_branch, + start_project: @start_project, + start_branch_name: @start_branch) - success - else + success + rescue Repository::CommitError => e + if e.message =~ /Failed to/ error_msg = "Sorry, we cannot #{action.to_s.dasherize} this #{@commit.change_type_title(current_user)} automatically. A #{action.to_s.dasherize} may have already been performed with this #{@commit.change_type_title(current_user)}, or a more recent commit may have updated some of its content." raise ChangeError, error_msg + else + raise end end @@ -66,16 +57,17 @@ module Commits true end - def validate_target_branch(new_branch) - # Temporary branch exists and contains the change commit - return if repository.find_branch(new_branch) - - result = ValidateNewBranchService.new(@project, current_user) - .execute(new_branch) + def validate_target_branch + result = ValidateNewBranchService.new(@project, current_user). + execute(@target_branch) if result[:status] == :error raise ChangeError, "There was an error creating the source branch: #{result[:message]}" end end + + def different_branch? + @start_branch != @target_branch || @start_project != @project + end end end diff --git a/app/views/projects/commit/_change.html.haml b/app/views/projects/commit/_change.html.haml index 421b3db342d..2ebd4f9069a 100644 --- a/app/views/projects/commit/_change.html.haml +++ b/app/views/projects/commit/_change.html.haml @@ -1,10 +1,10 @@ - case type.to_s - when 'revert' - label = 'Revert' - - target_label = 'Revert in branch' + - branch_label = 'Revert in branch' - when 'cherry-pick' - label = 'Cherry-pick' - - target_label = 'Pick into branch' + - branch_label = 'Pick into branch' .modal{ id: "modal-#{type}-commit" } .modal-dialog @@ -15,10 +15,10 @@ .modal-body = form_tag [type.underscore, @project.namespace.becomes(Namespace), @project, commit], method: :post, remote: false, class: "form-horizontal js-#{type}-form js-requires-input" do .form-group.branch - = label_tag 'target_branch', target_label, class: 'control-label' + = label_tag 'start_branch', branch_label, class: 'control-label' .col-sm-10 - = hidden_field_tag :target_branch, @project.default_branch, id: 'target_branch' - = dropdown_tag(@project.default_branch, options: { title: "Switch branch", filter: true, placeholder: "Search branches", toggle_class: 'js-project-refs-dropdown js-target-branch dynamic', dropdown_class: 'dropdown-menu-selectable', data: { field_name: "target_branch", selected: @project.default_branch, target_branch: @project.default_branch, refs_url: namespace_project_branches_path(@project.namespace, @project), submit_form_on_click: false } }) + = hidden_field_tag :start_branch, @project.default_branch, id: 'start_branch' + = dropdown_tag(@project.default_branch, options: { title: "Switch branch", filter: true, placeholder: "Search branches", toggle_class: 'js-project-refs-dropdown js-target-branch dynamic', dropdown_class: 'dropdown-menu-selectable', data: { field_name: "start_branch", selected: @project.default_branch, start_branch: @project.default_branch, refs_url: namespace_project_branches_path(@project.namespace, @project), submit_form_on_click: false } }) - if can?(current_user, :push_code, @project) .js-create-merge-request-container diff --git a/changelogs/unreleased/dm-fix-cherry-pick.yml b/changelogs/unreleased/dm-fix-cherry-pick.yml new file mode 100644 index 00000000000..e924b821d7e --- /dev/null +++ b/changelogs/unreleased/dm-fix-cherry-pick.yml @@ -0,0 +1,4 @@ +--- +title: Fix cherry-picking or reverting through an MR +merge_request: +author: diff --git a/features/steps/project/commits/revert.rb b/features/steps/project/commits/revert.rb index 94a5d4e2e4d..c9746407344 100644 --- a/features/steps/project/commits/revert.rb +++ b/features/steps/project/commits/revert.rb @@ -36,5 +36,6 @@ class Spinach::Features::RevertCommits < Spinach::FeatureSteps step 'I should see the new merge request notice' do page.should have_content('The commit has been successfully reverted. You can now submit a merge request to get this change into the original branch.') + page.should have_content("From revert-#{Commit.truncate_sha(sample_commit.id)} into master") end end diff --git a/lib/api/commits.rb b/lib/api/commits.rb index fd03e92264d..b0aa10f8bf2 100644 --- a/lib/api/commits.rb +++ b/lib/api/commits.rb @@ -127,7 +127,7 @@ module API commit_params = { commit: commit, - create_merge_request: false, + start_branch: params[:branch], target_branch: params[:branch] } diff --git a/lib/api/v3/commits.rb b/lib/api/v3/commits.rb index 506204b3517..d254d247042 100644 --- a/lib/api/v3/commits.rb +++ b/lib/api/v3/commits.rb @@ -130,9 +130,7 @@ module API commit_params = { commit: commit, - create_merge_request: false, - source_project: user_project, - source_branch: commit.cherry_pick_branch_name, + start_branch: params[:branch], target_branch: params[:branch] } diff --git a/spec/controllers/projects/commit_controller_spec.rb b/spec/controllers/projects/commit_controller_spec.rb index 640baa3a01c..b223a22ae60 100644 --- a/spec/controllers/projects/commit_controller_spec.rb +++ b/spec/controllers/projects/commit_controller_spec.rb @@ -166,7 +166,7 @@ describe Projects::CommitController do post(:revert, namespace_id: project.namespace, project_id: project, - target_branch: 'master', + start_branch: 'master', id: commit.id) expect(response).to redirect_to namespace_project_commits_path(project.namespace, project, 'master') @@ -179,7 +179,7 @@ describe Projects::CommitController do post(:revert, namespace_id: project.namespace, project_id: project, - target_branch: 'master', + start_branch: 'master', id: commit.id) end @@ -188,7 +188,7 @@ describe Projects::CommitController do post(:revert, namespace_id: project.namespace, project_id: project, - target_branch: 'master', + start_branch: 'master', id: commit.id) expect(response).to redirect_to namespace_project_commit_path(project.namespace, project, commit.id) @@ -215,7 +215,7 @@ describe Projects::CommitController do post(:cherry_pick, namespace_id: project.namespace, project_id: project, - target_branch: 'master', + start_branch: 'master', id: master_pickable_commit.id) expect(response).to redirect_to namespace_project_commits_path(project.namespace, project, 'master') @@ -228,7 +228,7 @@ describe Projects::CommitController do post(:cherry_pick, namespace_id: project.namespace, project_id: project, - target_branch: 'master', + start_branch: 'master', id: master_pickable_commit.id) end @@ -237,7 +237,7 @@ describe Projects::CommitController do post(:cherry_pick, namespace_id: project.namespace, project_id: project, - target_branch: 'master', + start_branch: 'master', id: master_pickable_commit.id) expect(response).to redirect_to namespace_project_commit_path(project.namespace, project, master_pickable_commit.id) diff --git a/spec/features/projects/commit/cherry_pick_spec.rb b/spec/features/projects/commit/cherry_pick_spec.rb index 7baf7913424..0b972d2a439 100644 --- a/spec/features/projects/commit/cherry_pick_spec.rb +++ b/spec/features/projects/commit/cherry_pick_spec.rb @@ -60,6 +60,7 @@ describe 'Cherry-pick Commits' do click_button 'Cherry-pick' end expect(page).to have_content('The commit has been successfully cherry-picked. You can now submit a merge request to get this change into the original branch.') + expect(page).to have_content("From cherry-pick-#{master_pickable_commit.short_id} into master") end end diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb index ae203fada12..eb992e1354e 100644 --- a/spec/models/repository_spec.rb +++ b/spec/models/repository_spec.rb @@ -1112,16 +1112,16 @@ describe Repository, models: true do let(:update_image_commit) { repository.commit('2f63565e7aac07bcdadb654e253078b727143ec4') } context 'when there is a conflict' do - it 'aborts the operation' do - expect(repository.revert(user, new_image_commit, 'master')).to eq(false) + it 'raises an error' do + expect { repository.revert(user, new_image_commit, 'master') }.to raise_error(/Failed to/) end end context 'when commit was already reverted' do - it 'aborts the operation' do + it 'raises an error' do repository.revert(user, update_image_commit, 'master') - expect(repository.revert(user, update_image_commit, 'master')).to eq(false) + expect { repository.revert(user, update_image_commit, 'master') }.to raise_error(/Failed to/) end end @@ -1148,16 +1148,16 @@ describe Repository, models: true do let(:pickable_merge) { repository.commit('e56497bb5f03a90a51293fc6d516788730953899') } context 'when there is a conflict' do - it 'aborts the operation' do - expect(repository.cherry_pick(user, conflict_commit, 'master')).to eq(false) + it 'raises an error' do + expect { repository.cherry_pick(user, conflict_commit, 'master') }.to raise_error(/Failed to/) end end context 'when commit was already cherry-picked' do - it 'aborts the operation' do + it 'raises an error' do repository.cherry_pick(user, pickable_commit, 'master') - expect(repository.cherry_pick(user, pickable_commit, 'master')).to eq(false) + expect { repository.cherry_pick(user, pickable_commit, 'master') }.to raise_error(/Failed to/) end end -- cgit v1.2.1 From caed61adeee1b5f1bf6943abd53e381ca31588c6 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Thu, 2 Mar 2017 18:11:23 -0600 Subject: Use separate error class for cherry-pick and revert tree errors --- app/models/repository.rb | 5 +++-- app/services/commits/change_service.rb | 14 +++++--------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/app/models/repository.rb b/app/models/repository.rb index 4f8b436ee16..e7cc8d6e083 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -6,6 +6,7 @@ class Repository attr_accessor :path_with_namespace, :project CommitError = Class.new(StandardError) + CreateTreeError = Class.new(StandardError) # Methods that cache data from the Git repository. # @@ -871,7 +872,7 @@ class Repository revert_tree_id = check_revert_content(commit, start_commit.sha) unless revert_tree_id - raise Repository::CommitError.new('Failed to revert commit') + raise Repository::CreateTreeError.new('Failed to revert commit') end committer = user_to_committer(user) @@ -895,7 +896,7 @@ class Repository cherry_pick_tree_id = check_cherry_pick_content(commit, start_commit.sha) unless cherry_pick_tree_id - raise Repository::CommitError.new('Failed to cherry-pick commit') + raise Repository::CreateTreeError.new('Failed to cherry-pick commit') end committer = user_to_committer(user) diff --git a/app/services/commits/change_service.rb b/app/services/commits/change_service.rb index 3051ebf5e52..3dcecee9365 100644 --- a/app/services/commits/change_service.rb +++ b/app/services/commits/change_service.rb @@ -37,14 +37,10 @@ module Commits start_branch_name: @start_branch) success - rescue Repository::CommitError => e - if e.message =~ /Failed to/ - error_msg = "Sorry, we cannot #{action.to_s.dasherize} this #{@commit.change_type_title(current_user)} automatically. + rescue Repository::CreateTreeError => e + error_msg = "Sorry, we cannot #{action.to_s.dasherize} this #{@commit.change_type_title(current_user)} automatically. A #{action.to_s.dasherize} may have already been performed with this #{@commit.change_type_title(current_user)}, or a more recent commit may have updated some of its content." - raise ChangeError, error_msg - else - raise - end + raise ChangeError, error_msg end def check_push_permissions @@ -58,8 +54,8 @@ module Commits end def validate_target_branch - result = ValidateNewBranchService.new(@project, current_user). - execute(@target_branch) + result = ValidateNewBranchService.new(@project, current_user) + .execute(@target_branch) if result[:status] == :error raise ChangeError, "There was an error creating the source branch: #{result[:message]}" -- cgit v1.2.1 From b3971d0f213d1fe8129111009ec69076f7fb4233 Mon Sep 17 00:00:00 2001 From: Dmitriy Zaporozhets Date: Tue, 28 Feb 2017 17:08:46 +0200 Subject: Change default project view for user from readme to files view Signed-off-by: Dmitriy Zaporozhets --- app/helpers/preferences_helper.rb | 4 ++-- app/models/user.rb | 1 + changelogs/unreleased/dz-change-project-view.yml | 4 ++++ 3 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 changelogs/unreleased/dz-change-project-view.yml diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb index c3a08d76318..74cccb23956 100644 --- a/app/helpers/preferences_helper.rb +++ b/app/helpers/preferences_helper.rb @@ -35,9 +35,9 @@ module PreferencesHelper def project_view_choices [ - ['Readme (default)', :readme], + ['Readme', :readme], ['Activity view', :activity], - ['Files view', :files] + ['Files and Readme (default)', :files] ] end diff --git a/app/models/user.rb b/app/models/user.rb index 8443594c055..d3bb04060bf 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -21,6 +21,7 @@ class User < ActiveRecord::Base default_value_for :can_create_team, false default_value_for :hide_no_ssh_key, false default_value_for :hide_no_password, false + default_value_for :project_view, :files attr_encrypted :otp_secret, key: Gitlab::Application.secrets.otp_key_base, diff --git a/changelogs/unreleased/dz-change-project-view.yml b/changelogs/unreleased/dz-change-project-view.yml new file mode 100644 index 00000000000..47e007a80a8 --- /dev/null +++ b/changelogs/unreleased/dz-change-project-view.yml @@ -0,0 +1,4 @@ +--- +title: Change default project view for user from readme to files view +merge_request: 9584 +author: -- cgit v1.2.1 From 59e7d04bc78a69137b649dc8ac470a6c3eedbd3c Mon Sep 17 00:00:00 2001 From: Toon Claes Date: Mon, 30 Jan 2017 12:11:58 +0100 Subject: Expose pipelines as PipelineBasic `projects/:id/pipelines` The `projects/:id/pipelines` exposed a lot of extra details that are superfluous and it was taking extra resources to fetch them. To get more details about a pipeline, use `projects/:id/pipelines/:pipeline_id`. --- .../unreleased/26847-api-pipelines-use-basic.yml | 4 + doc/api/pipelines.md | 40 +--- doc/api/v3_to_v4.md | 1 + lib/api/api.rb | 1 + lib/api/pipelines.rb | 4 +- lib/api/v3/pipelines.rb | 36 ++++ spec/requests/api/pipelines_spec.rb | 1 + spec/requests/api/v3/pipelines_spec.rb | 203 +++++++++++++++++++++ 8 files changed, 250 insertions(+), 40 deletions(-) create mode 100644 changelogs/unreleased/26847-api-pipelines-use-basic.yml create mode 100644 lib/api/v3/pipelines.rb create mode 100644 spec/requests/api/v3/pipelines_spec.rb diff --git a/changelogs/unreleased/26847-api-pipelines-use-basic.yml b/changelogs/unreleased/26847-api-pipelines-use-basic.yml new file mode 100644 index 00000000000..2034a4ba080 --- /dev/null +++ b/changelogs/unreleased/26847-api-pipelines-use-basic.yml @@ -0,0 +1,4 @@ +--- +title: Expose pipelines as PipelineBasic `api/v3/projects/:id/pipelines` +merge_request: 8875 +author: diff --git a/doc/api/pipelines.md b/doc/api/pipelines.md index 9d6f3ea41d9..d809ec032f8 100644 --- a/doc/api/pipelines.md +++ b/doc/api/pipelines.md @@ -24,49 +24,13 @@ Example of response "id": 47, "status": "pending", "ref": "new-pipeline", - "sha": "a91957a858320c0e17f3a0eca7cfacbff50ea29a", - "before_sha": "a91957a858320c0e17f3a0eca7cfacbff50ea29a", - "tag": false, - "yaml_errors": null, - "user": { - "name": "Administrator", - "username": "root", - "id": 1, - "state": "active", - "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", - "web_url": "http://localhost:3000/root" - }, - "created_at": "2016-08-16T10:23:19.007Z", - "updated_at": "2016-08-16T10:23:19.216Z", - "started_at": null, - "finished_at": null, - "committed_at": null, - "duration": null, - "coverage": "30.0" + "sha": "a91957a858320c0e17f3a0eca7cfacbff50ea29a" }, { "id": 48, "status": "pending", "ref": "new-pipeline", - "sha": "eb94b618fb5865b26e80fdd8ae531b7a63ad851a", - "before_sha": "eb94b618fb5865b26e80fdd8ae531b7a63ad851a", - "tag": false, - "yaml_errors": null, - "user": { - "name": "Administrator", - "username": "root", - "id": 1, - "state": "active", - "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", - "web_url": "http://localhost:3000/root" - }, - "created_at": "2016-08-16T10:23:21.184Z", - "updated_at": "2016-08-16T10:23:21.314Z", - "started_at": null, - "finished_at": null, - "committed_at": null, - "duration": null, - "coverage": null + "sha": "eb94b618fb5865b26e80fdd8ae531b7a63ad851a" } ] ``` diff --git a/doc/api/v3_to_v4.md b/doc/api/v3_to_v4.md index cca58894476..ba5b7e09102 100644 --- a/doc/api/v3_to_v4.md +++ b/doc/api/v3_to_v4.md @@ -53,3 +53,4 @@ changes are in V4: - Remove `GET /groups/owned`. Use `GET /groups?owned=true` instead [!9505](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/9505) - Return 202 with JSON body on async removals on V4 API (DELETE `/projects/:id/repository/merged_branches` and DELETE `/projects/:id`) [!9449](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/9449) - `projects/:id/milestones?iid[]=x&iid[]=y` array filter has been renamed to `iids` [!9096](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/9096) +- Return basic info about pipeline in `GET /projects/:id/pipelines` [!8875](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/8875) diff --git a/lib/api/api.rb b/lib/api/api.rb index 3e53ab693ab..89449ce8813 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -20,6 +20,7 @@ module API mount ::API::V3::MergeRequestDiffs mount ::API::V3::MergeRequests mount ::API::V3::Notes + mount ::API::V3::Pipelines mount ::API::V3::ProjectHooks mount ::API::V3::Milestones mount ::API::V3::Projects diff --git a/lib/api/pipelines.rb b/lib/api/pipelines.rb index 3afc1e385fe..0721b975ba4 100644 --- a/lib/api/pipelines.rb +++ b/lib/api/pipelines.rb @@ -10,7 +10,7 @@ module API resource :projects do desc 'Get all Pipelines of the project' do detail 'This feature was introduced in GitLab 8.11.' - success Entities::Pipeline + success Entities::PipelineBasic end params do use :pagination @@ -21,7 +21,7 @@ module API authorize! :read_pipeline, user_project pipelines = PipelinesFinder.new(user_project).execute(scope: params[:scope]) - present paginate(pipelines), with: Entities::Pipeline + present paginate(pipelines), with: Entities::PipelineBasic end desc 'Create a new pipeline' do diff --git a/lib/api/v3/pipelines.rb b/lib/api/v3/pipelines.rb new file mode 100644 index 00000000000..2c26a5f7d35 --- /dev/null +++ b/lib/api/v3/pipelines.rb @@ -0,0 +1,36 @@ +module API + module V3 + class Pipelines < Grape::API + include PaginationParams + + before { authenticate! } + + params do + requires :id, type: String, desc: 'The project ID' + end + resource :projects do + desc 'Get all Pipelines of the project' do + detail 'This feature was introduced in GitLab 8.11.' + success ::API::Entities::Pipeline + end + params do + use :pagination + optional :scope, type: String, values: %w(running branches tags), + desc: 'Either running, branches, or tags' + end + get ':id/pipelines' do + authorize! :read_pipeline, user_project + + pipelines = PipelinesFinder.new(user_project).execute(scope: params[:scope]) + present paginate(pipelines), with: ::API::Entities::Pipeline + end + end + + helpers do + def pipeline + @pipeline ||= user_project.pipelines.find(params[:pipeline_id]) + end + end + end + end +end diff --git a/spec/requests/api/pipelines_spec.rb b/spec/requests/api/pipelines_spec.rb index 98d004b572e..51af999b455 100644 --- a/spec/requests/api/pipelines_spec.rb +++ b/spec/requests/api/pipelines_spec.rb @@ -24,6 +24,7 @@ describe API::Pipelines, api: true do expect(json_response).to be_an Array expect(json_response.first['sha']).to match /\A\h{40}\z/ expect(json_response.first['id']).to eq pipeline.id + expect(json_response.first.keys).to contain_exactly(*%w[id sha ref status]) end end diff --git a/spec/requests/api/v3/pipelines_spec.rb b/spec/requests/api/v3/pipelines_spec.rb new file mode 100644 index 00000000000..3786eb06932 --- /dev/null +++ b/spec/requests/api/v3/pipelines_spec.rb @@ -0,0 +1,203 @@ +require 'spec_helper' + +describe API::V3::Pipelines, api: true do + include ApiHelpers + + let(:user) { create(:user) } + let(:non_member) { create(:user) } + let(:project) { create(:project, :repository, creator: user) } + + let!(:pipeline) do + create(:ci_empty_pipeline, project: project, sha: project.commit.id, + ref: project.default_branch) + end + + before { project.team << [user, :master] } + + shared_examples 'a paginated resources' do + before do + # Fires the request + request + end + + it 'has pagination headers' do + expect(response).to include_pagination_headers + end + end + + describe 'GET /projects/:id/pipelines ' do + it_behaves_like 'a paginated resources' do + let(:request) { get v3_api("/projects/#{project.id}/pipelines", user) } + end + + context 'authorized user' do + it 'returns project pipelines' do + get v3_api("/projects/#{project.id}/pipelines", user) + + expect(response).to have_http_status(200) + expect(json_response).to be_an Array + expect(json_response.first['sha']).to match(/\A\h{40}\z/) + expect(json_response.first['id']).to eq pipeline.id + expect(json_response.first.keys).to contain_exactly(*%w[id sha ref status before_sha tag yaml_errors user created_at updated_at started_at finished_at committed_at duration coverage]) + end + end + + context 'unauthorized user' do + it 'does not return project pipelines' do + get v3_api("/projects/#{project.id}/pipelines", non_member) + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq '404 Project Not Found' + expect(json_response).not_to be_an Array + end + end + end + + describe 'POST /projects/:id/pipeline ' do + context 'authorized user' do + context 'with gitlab-ci.yml' do + before { stub_ci_pipeline_to_return_yaml_file } + + it 'creates and returns a new pipeline' do + expect do + post v3_api("/projects/#{project.id}/pipeline", user), ref: project.default_branch + end.to change { Ci::Pipeline.count }.by(1) + + expect(response).to have_http_status(201) + expect(json_response).to be_a Hash + expect(json_response['sha']).to eq project.commit.id + end + + it 'fails when using an invalid ref' do + post v3_api("/projects/#{project.id}/pipeline", user), ref: 'invalid_ref' + + expect(response).to have_http_status(400) + expect(json_response['message']['base'].first).to eq 'Reference not found' + expect(json_response).not_to be_an Array + end + end + + context 'without gitlab-ci.yml' do + it 'fails to create pipeline' do + post v3_api("/projects/#{project.id}/pipeline", user), ref: project.default_branch + + expect(response).to have_http_status(400) + expect(json_response['message']['base'].first).to eq 'Missing .gitlab-ci.yml file' + expect(json_response).not_to be_an Array + end + end + end + + context 'unauthorized user' do + it 'does not create pipeline' do + post v3_api("/projects/#{project.id}/pipeline", non_member), ref: project.default_branch + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq '404 Project Not Found' + expect(json_response).not_to be_an Array + end + end + end + + describe 'GET /projects/:id/pipelines/:pipeline_id' do + context 'authorized user' do + it 'returns project pipelines' do + get v3_api("/projects/#{project.id}/pipelines/#{pipeline.id}", user) + + expect(response).to have_http_status(200) + expect(json_response['sha']).to match /\A\h{40}\z/ + end + + it 'returns 404 when it does not exist' do + get v3_api("/projects/#{project.id}/pipelines/123456", user) + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq '404 Not found' + expect(json_response['id']).to be nil + end + + context 'with coverage' do + before do + create(:ci_build, coverage: 30, pipeline: pipeline) + end + + it 'exposes the coverage' do + get v3_api("/projects/#{project.id}/pipelines/#{pipeline.id}", user) + + expect(json_response["coverage"].to_i).to eq(30) + end + end + end + + context 'unauthorized user' do + it 'should not return a project pipeline' do + get v3_api("/projects/#{project.id}/pipelines/#{pipeline.id}", non_member) + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq '404 Project Not Found' + expect(json_response['id']).to be nil + end + end + end + + describe 'POST /projects/:id/pipelines/:pipeline_id/retry' do + context 'authorized user' do + let!(:pipeline) do + create(:ci_pipeline, project: project, sha: project.commit.id, + ref: project.default_branch) + end + + let!(:build) { create(:ci_build, :failed, pipeline: pipeline) } + + it 'retries failed builds' do + expect do + post v3_api("/projects/#{project.id}/pipelines/#{pipeline.id}/retry", user) + end.to change { pipeline.builds.count }.from(1).to(2) + + expect(response).to have_http_status(201) + expect(build.reload.retried?).to be true + end + end + + context 'unauthorized user' do + it 'should not return a project pipeline' do + post v3_api("/projects/#{project.id}/pipelines/#{pipeline.id}/retry", non_member) + + expect(response).to have_http_status(404) + expect(json_response['message']).to eq '404 Project Not Found' + expect(json_response['id']).to be nil + end + end + end + + describe 'POST /projects/:id/pipelines/:pipeline_id/cancel' do + let!(:pipeline) do + create(:ci_empty_pipeline, project: project, sha: project.commit.id, + ref: project.default_branch) + end + + let!(:build) { create(:ci_build, :running, pipeline: pipeline) } + + context 'authorized user' do + it 'retries failed builds' do + post v3_api("/projects/#{project.id}/pipelines/#{pipeline.id}/cancel", user) + + expect(response).to have_http_status(200) + expect(json_response['status']).to eq('canceled') + end + end + + context 'user without proper access rights' do + let!(:reporter) { create(:user) } + + before { project.team << [reporter, :reporter] } + + it 'rejects the action' do + post v3_api("/projects/#{project.id}/pipelines/#{pipeline.id}/cancel", reporter) + + expect(response).to have_http_status(403) + expect(pipeline.reload.status).to eq('pending') + end + end + end +end -- cgit v1.2.1 From 06e96907eef49c3be1b3608e77420e42d554943a Mon Sep 17 00:00:00 2001 From: Oswaldo Ferreira Date: Thu, 2 Mar 2017 16:47:10 -0300 Subject: Add filter param for authorized projects for current_user for V4 --- .../28865-filter-by-authorized-projects-in-v4.yml | 4 ++ doc/api/projects.md | 1 + doc/api/v3_to_v4.md | 1 + lib/api/helpers.rb | 4 ++ spec/requests/api/projects_spec.rb | 69 +++++++++++++++------- 5 files changed, 57 insertions(+), 22 deletions(-) create mode 100644 changelogs/unreleased/28865-filter-by-authorized-projects-in-v4.yml diff --git a/changelogs/unreleased/28865-filter-by-authorized-projects-in-v4.yml b/changelogs/unreleased/28865-filter-by-authorized-projects-in-v4.yml new file mode 100644 index 00000000000..8615c17666e --- /dev/null +++ b/changelogs/unreleased/28865-filter-by-authorized-projects-in-v4.yml @@ -0,0 +1,4 @@ +--- +title: Add filter param for authorized projects for current_user for V4 +merge_request: +author: diff --git a/doc/api/projects.md b/doc/api/projects.md index f6eabc1f663..6805e7d67e9 100644 --- a/doc/api/projects.md +++ b/doc/api/projects.md @@ -37,6 +37,7 @@ Parameters: | `search` | string | no | Return list of authorized projects matching the search criteria | | `simple` | boolean | no | Return only the ID, URL, name, and path of each project | | `owned` | boolean | no | Limit by projects owned by the current user | +| `authorized` | boolean | no | Limit by projects authorized for the current user | | `starred` | boolean | no | Limit by projects starred by the current user | ```json diff --git a/doc/api/v3_to_v4.md b/doc/api/v3_to_v4.md index cca58894476..92f2e02405d 100644 --- a/doc/api/v3_to_v4.md +++ b/doc/api/v3_to_v4.md @@ -53,3 +53,4 @@ changes are in V4: - Remove `GET /groups/owned`. Use `GET /groups?owned=true` instead [!9505](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/9505) - Return 202 with JSON body on async removals on V4 API (DELETE `/projects/:id/repository/merged_branches` and DELETE `/projects/:id`) [!9449](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/9449) - `projects/:id/milestones?iid[]=x&iid[]=y` array filter has been renamed to `iids` [!9096](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/9096) +- Enable filtering user's authorized projects with boolean param `authorized` on `/projects` endpoint [!9674](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/9674) diff --git a/lib/api/helpers.rb b/lib/api/helpers.rb index 4600abc7dc7..cf57cb1b825 100644 --- a/lib/api/helpers.rb +++ b/lib/api/helpers.rb @@ -252,6 +252,10 @@ module API # project helpers def filter_projects(projects) + if params[:authorized] + projects = projects.merge(current_user.authorized_projects) + end + if params[:owned] projects = projects.merge(current_user.owned_projects) end diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb index 2e8b557e9e2..eb2380620e2 100644 --- a/spec/requests/api/projects_spec.rb +++ b/spec/requests/api/projects_spec.rb @@ -43,9 +43,10 @@ describe API::Projects, api: true do describe 'GET /projects' do shared_examples_for 'projects response' do it 'returns an array of projects' do - get api('/projects', current_user) + get api('/projects', current_user), filter expect(response).to have_http_status(200) + expect(response).to include_pagination_headers expect(json_response).to be_an Array expect(json_response.map { |p| p['id'] }).to contain_exactly(*projects.map(&:id)) end @@ -61,6 +62,7 @@ describe API::Projects, api: true do context 'when unauthenticated' do it_behaves_like 'projects response' do + let(:filter) { {} } let(:current_user) { nil } let(:projects) { [public_project] } end @@ -68,6 +70,7 @@ describe API::Projects, api: true do context 'when authenticated as regular user' do it_behaves_like 'projects response' do + let(:filter) { {} } let(:current_user) { user } let(:projects) { [public_project, project, project2, project3] } end @@ -133,13 +136,18 @@ describe API::Projects, api: true do end context 'and using search' do - it 'returns searched project' do - get api('/projects', user), { search: project.name } + it_behaves_like 'projects response' do + let(:filter) { { search: project.name } } + let(:current_user) { user } + let(:projects) { [project] } + end + end - expect(response).to have_http_status(200) - expect(response).to include_pagination_headers - expect(json_response).to be_an Array - expect(json_response.length).to eq(1) + context 'and authorized=true' do + it_behaves_like 'projects response' do + let(:filter) { { authorized: true } } + let(:current_user) { user } + let(:projects) { [project, project2, project3] } end end @@ -216,36 +224,52 @@ describe API::Projects, api: true do end context 'and with all query parameters' do - # | | project5 | project6 | project7 | project8 | project9 | - # |---------+----------+----------+----------+----------+----------| - # | search | x | | x | x | x | - # | starred | x | x | | x | x | - # | public | x | x | x | | x | - # | owned | x | x | x | x | | - let!(:project5) { create(:empty_project, :public, path: 'gitlab5', namespace: user.namespace) } + let!(:project5) { create(:empty_project, :public, path: 'gitlab5', namespace: create(:namespace)) } let!(:project6) { create(:empty_project, :public, path: 'project6', namespace: user.namespace) } let!(:project7) { create(:empty_project, :public, path: 'gitlab7', namespace: user.namespace) } let!(:project8) { create(:empty_project, path: 'gitlab8', namespace: user.namespace) } let!(:project9) { create(:empty_project, :public, path: 'gitlab9') } before do - user.update_attributes(starred_projects: [project5, project6, project8, project9]) + user.update_attributes(starred_projects: [project5, project7, project8, project9]) end - it 'returns only projects that satify all query parameters' do - get api('/projects', user), { visibility: 'public', owned: true, starred: true, search: 'gitlab' } + context 'including owned filter' do + it 'returns only projects that satify all query parameters' do + get api('/projects', user), { visibility: 'public', owned: true, starred: true, search: 'gitlab' } - expect(response).to have_http_status(200) - expect(response).to include_pagination_headers - expect(json_response).to be_an Array - expect(json_response.size).to eq(1) - expect(json_response.first['id']).to eq(project5.id) + expect(response).to have_http_status(200) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response.size).to eq(1) + expect(json_response.first['id']).to eq(project7.id) + end + end + + context 'including authorized filter' do + before do + create(:project_member, + user: user, + project: project5, + access_level: ProjectMember::MASTER) + end + + it 'returns only projects that satify all query parameters' do + get api('/projects', user), { visibility: 'public', authorized: true, starred: true, search: 'gitlab' } + + expect(response).to have_http_status(200) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response.size).to eq(2) + expect(json_response.map { |project| project.fetch('id') }).to contain_exactly(project5.id, project7.id) + end end end end context 'when authenticated as a different user' do it_behaves_like 'projects response' do + let(:filter) { {} } let(:current_user) { user2 } let(:projects) { [public_project] } end @@ -253,6 +277,7 @@ describe API::Projects, api: true do context 'when authenticated as admin' do it_behaves_like 'projects response' do + let(:filter) { {} } let(:current_user) { admin } let(:projects) { Project.all } end -- cgit v1.2.1 From 6357635686fafb2fc9af5090c1edabfe25649085 Mon Sep 17 00:00:00 2001 From: Toon Claes Date: Fri, 3 Mar 2017 12:00:34 +0100 Subject: Rename query parameter to `membership` The query parameter `membership` should be more self-explaining. --- .../28865-filter-by-authorized-projects-in-v4.yml | 2 +- doc/api/projects.md | 4 ++-- doc/api/v3_to_v4.md | 8 ++++++-- lib/api/helpers.rb | 2 +- lib/api/projects.rb | 3 ++- spec/requests/api/projects_spec.rb | 14 +++++++------- 6 files changed, 19 insertions(+), 14 deletions(-) diff --git a/changelogs/unreleased/28865-filter-by-authorized-projects-in-v4.yml b/changelogs/unreleased/28865-filter-by-authorized-projects-in-v4.yml index 8615c17666e..7c64783cbd0 100644 --- a/changelogs/unreleased/28865-filter-by-authorized-projects-in-v4.yml +++ b/changelogs/unreleased/28865-filter-by-authorized-projects-in-v4.yml @@ -1,4 +1,4 @@ --- -title: Add filter param for authorized projects for current_user for V4 +title: Add filter param for project membership for current_user in API v4 merge_request: author: diff --git a/doc/api/projects.md b/doc/api/projects.md index 6805e7d67e9..6062c5ccd71 100644 --- a/doc/api/projects.md +++ b/doc/api/projects.md @@ -34,10 +34,10 @@ Parameters: | `visibility` | string | no | Limit by visibility `public`, `internal`, or `private` | | `order_by` | string | no | Return projects ordered by `id`, `name`, `path`, `created_at`, `updated_at`, or `last_activity_at` fields. Default is `created_at` | | `sort` | string | no | Return projects sorted in `asc` or `desc` order. Default is `desc` | -| `search` | string | no | Return list of authorized projects matching the search criteria | +| `search` | string | no | Return list of projects matching the search criteria | | `simple` | boolean | no | Return only the ID, URL, name, and path of each project | | `owned` | boolean | no | Limit by projects owned by the current user | -| `authorized` | boolean | no | Limit by projects authorized for the current user | +| `membership` | boolean | no | Limit by projects that the current user is a member of | | `starred` | boolean | no | Limit by projects starred by the current user | ```json diff --git a/doc/api/v3_to_v4.md b/doc/api/v3_to_v4.md index 92f2e02405d..42deccba0a6 100644 --- a/doc/api/v3_to_v4.md +++ b/doc/api/v3_to_v4.md @@ -28,7 +28,12 @@ changes are in V4: - `/dockerfiles/:key` - Moved `/projects/fork/:id` to `/projects/:id/fork` [!8940](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/8940) - Moved `DELETE /todos` to `POST /todos/mark_as_done` and `DELETE /todos/:todo_id` to `POST /todos/:todo_id/mark_as_done` [!9410](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/9410) -- Endpoints `/projects/owned`, `/projects/visible`, `/projects/starred` & `/projects/all` are consolidated into `/projects` using query parameters [!8962](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/8962) +- Project filters are no longer available as `GET /projects/foo`, but as `GET /projects?foo=true` instead [!8962](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/8962) + - `GET /projects/visible` & `GET /projects/all` are consolidated into `GET /projects` and can be used with or without authorization + - `GET /projects/owned` moved to `GET /projects?owned=true` + - `GET /projects/starred` moved to `GET /projects?starred=true` +- `GET /projects` returns all projects visible to current user, even if the user is not a member [!9674](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/9674) + - To get projects the user is a member of, use `/projects?membership=true` - Return pagination headers for all endpoints that return an array [!8606](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/8606) - Added `POST /environments/:environment_id/stop` to stop an environment [!8808](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/8808) - Removed `DELETE projects/:id/deploy_keys/:key_id/disable`. Use `DELETE projects/:id/deploy_keys/:key_id` instead [!9366](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/9366) @@ -53,4 +58,3 @@ changes are in V4: - Remove `GET /groups/owned`. Use `GET /groups?owned=true` instead [!9505](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/9505) - Return 202 with JSON body on async removals on V4 API (DELETE `/projects/:id/repository/merged_branches` and DELETE `/projects/:id`) [!9449](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/9449) - `projects/:id/milestones?iid[]=x&iid[]=y` array filter has been renamed to `iids` [!9096](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/9096) -- Enable filtering user's authorized projects with boolean param `authorized` on `/projects` endpoint [!9674](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/9674) diff --git a/lib/api/helpers.rb b/lib/api/helpers.rb index cf57cb1b825..9c41146f1e3 100644 --- a/lib/api/helpers.rb +++ b/lib/api/helpers.rb @@ -252,7 +252,7 @@ module API # project helpers def filter_projects(projects) - if params[:authorized] + if params[:membership] projects = projects.merge(current_user.authorized_projects) end diff --git a/lib/api/projects.rb b/lib/api/projects.rb index f302496c12b..63a4cdd5954 100644 --- a/lib/api/projects.rb +++ b/lib/api/projects.rb @@ -46,9 +46,10 @@ module API optional :archived, type: Boolean, default: false, desc: 'Limit by archived status' optional :visibility, type: String, values: Gitlab::VisibilityLevel.string_values, desc: 'Limit by visibility' - optional :search, type: String, desc: 'Return list of authorized projects matching the search criteria' + optional :search, type: String, desc: 'Return list of projects matching the search criteria' optional :owned, type: Boolean, default: false, desc: 'Limit by owned by authenticated user' optional :starred, type: Boolean, default: false, desc: 'Limit by starred status' + optional :membership, type: Boolean, default: false, desc: 'Limit by projects that the current user is a member of' end params :statistics_params do diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb index eb2380620e2..03cae074803 100644 --- a/spec/requests/api/projects_spec.rb +++ b/spec/requests/api/projects_spec.rb @@ -143,9 +143,9 @@ describe API::Projects, api: true do end end - context 'and authorized=true' do + context 'and membership=true' do it_behaves_like 'projects response' do - let(:filter) { { authorized: true } } + let(:filter) { { membership: true } } let(:current_user) { user } let(:projects) { [project, project2, project3] } end @@ -235,7 +235,7 @@ describe API::Projects, api: true do end context 'including owned filter' do - it 'returns only projects that satify all query parameters' do + it 'returns only projects that satisfy all query parameters' do get api('/projects', user), { visibility: 'public', owned: true, starred: true, search: 'gitlab' } expect(response).to have_http_status(200) @@ -246,7 +246,7 @@ describe API::Projects, api: true do end end - context 'including authorized filter' do + context 'including membership filter' do before do create(:project_member, user: user, @@ -254,14 +254,14 @@ describe API::Projects, api: true do access_level: ProjectMember::MASTER) end - it 'returns only projects that satify all query parameters' do - get api('/projects', user), { visibility: 'public', authorized: true, starred: true, search: 'gitlab' } + it 'returns only projects that satisfy all query parameters' do + get api('/projects', user), { visibility: 'public', membership: true, starred: true, search: 'gitlab' } expect(response).to have_http_status(200) expect(response).to include_pagination_headers expect(json_response).to be_an Array expect(json_response.size).to eq(2) - expect(json_response.map { |project| project.fetch('id') }).to contain_exactly(project5.id, project7.id) + expect(json_response.map { |project| project['id'] }).to contain_exactly(project5.id, project7.id) end end end -- cgit v1.2.1 From 358897bcbfa7e907511f29cf80acc315ade160b1 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 3 Mar 2017 07:23:49 -0600 Subject: Remove useless assignment --- app/services/commits/change_service.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/commits/change_service.rb b/app/services/commits/change_service.rb index 3dcecee9365..1297a792259 100644 --- a/app/services/commits/change_service.rb +++ b/app/services/commits/change_service.rb @@ -37,7 +37,7 @@ module Commits start_branch_name: @start_branch) success - rescue Repository::CreateTreeError => e + rescue Repository::CreateTreeError error_msg = "Sorry, we cannot #{action.to_s.dasherize} this #{@commit.change_type_title(current_user)} automatically. A #{action.to_s.dasherize} may have already been performed with this #{@commit.change_type_title(current_user)}, or a more recent commit may have updated some of its content." raise ChangeError, error_msg -- cgit v1.2.1 From ed1f110499d8e2aa011e17f1ad8a038e209842fb Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Wed, 1 Mar 2017 16:17:26 +0000 Subject: Fixes filtering by name reseting archive filter Previously the search form just wasn't including any params that had previously been set, so when you filtered by name, it would reset all the params & therefore ignoring the archived param Closes #28007 --- app/controllers/concerns/filter_projects.rb | 2 +- app/views/admin/projects/index.html.haml | 11 ++--------- app/views/dashboard/_projects_head.html.haml | 5 +++-- app/views/groups/show.html.haml | 5 +++-- app/views/shared/projects/_dropdown.html.haml | 17 +++++++++-------- app/views/shared/projects/_filter_fields.html.haml | 11 +++++++++++ .../unreleased/dashboard-filter-search-keep-params.yml | 4 ++++ spec/features/dashboard/archived_projects_spec.rb | 15 +++++++++++++++ 8 files changed, 48 insertions(+), 22 deletions(-) create mode 100644 app/views/shared/projects/_filter_fields.html.haml create mode 100644 changelogs/unreleased/dashboard-filter-search-keep-params.yml diff --git a/app/controllers/concerns/filter_projects.rb b/app/controllers/concerns/filter_projects.rb index 586f97c5eb4..6014112256a 100644 --- a/app/controllers/concerns/filter_projects.rb +++ b/app/controllers/concerns/filter_projects.rb @@ -8,7 +8,7 @@ module FilterProjects extend ActiveSupport::Concern def filter_projects(projects) - projects = projects.search(params[:filter_projects]) if params[:filter_projects].present? + projects = projects.search(params[:name]) if params[:name].present? projects = projects.non_archived if params[:archived].blank? projects = projects.personal(current_user) if params[:personal].present? && current_user diff --git a/app/views/admin/projects/index.html.haml b/app/views/admin/projects/index.html.haml index c35945c5a35..121662a2ea6 100644 --- a/app/views/admin/projects/index.html.haml +++ b/app/views/admin/projects/index.html.haml @@ -8,6 +8,7 @@ .top-area .prepend-top-default = form_tag admin_projects_path, method: :get do |f| + = render "shared/projects/filter_fields" .search-holder .search-field-holder = search_field_tag :name, params[:name], class: "form-control search-text-input js-search-input", id: "dashboard_search", autofocus: true, spellcheck: false, placeholder: 'Search by name' @@ -15,20 +16,12 @@ - if params[:visibility_level].present? = hidden_field_tag 'visibility_level', params[:visibility_level] - - if params[:sort].present? - = hidden_field_tag 'sort', params[:sort] - - - if params[:personal].present? - = hidden_field_tag 'visibility_level', 'true' - - - if params[:archived].present? - = hidden_field_tag 'archived', 'true' - = icon("search", class: "search-icon") .dropdown - toggle_text = 'Namespace' - if params[:namespace_id].present? + = hidden_field_tag :namespace_id, params[:namespace_id] - namespace = Namespace.find(params[:namespace_id]) - toggle_text = "#{namespace.kind}: #{namespace.full_path}" = dropdown_toggle(toggle_text, { toggle: 'dropdown' }, { toggle_class: 'js-namespace-select large' }) diff --git a/app/views/dashboard/_projects_head.html.haml b/app/views/dashboard/_projects_head.html.haml index 48b0fd504f4..795ad9f33c1 100644 --- a/app/views/dashboard/_projects_head.html.haml +++ b/app/views/dashboard/_projects_head.html.haml @@ -13,8 +13,9 @@ Explore projects .nav-controls - = form_tag request.path, method: :get, class: 'project-filter-form', id: 'project-filter-form' do |f| - = search_field_tag :filter_projects, params[:filter_projects], placeholder: 'Filter by name...', class: 'project-filter-form-field form-control input-short projects-list-filter', spellcheck: false, id: 'project-filter-form-field', tabindex: "2" + = form_tag request.fullpath, method: :get, class: 'project-filter-form', id: 'project-filter-form' do |f| + = render "shared/projects/filter_fields" + = search_field_tag :name, params[:name], placeholder: 'Filter by name...', class: 'project-filter-form-field form-control input-short projects-list-filter', spellcheck: false, id: 'project-filter-form-field', tabindex: "2" = render 'shared/projects/dropdown' - if current_user.can_create_project? = link_to new_project_path, class: 'btn btn-new' do diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml index 8f0f2708194..5a7b0c1534b 100644 --- a/app/views/groups/show.html.haml +++ b/app/views/groups/show.html.haml @@ -11,8 +11,9 @@ .top-area = render 'groups/show_nav' .nav-controls - = form_tag request.path, method: :get, class: 'project-filter-form', id: 'project-filter-form' do |f| - = search_field_tag :filter_projects, nil, placeholder: 'Filter by name', class: 'projects-list-filter form-control', spellcheck: false + = form_tag request.fullpath, method: :get, class: 'project-filter-form', id: 'project-filter-form' do |f| + = render "shared/projects/filter_fields" + = search_field_tag :name, nil, placeholder: 'Filter by name', class: 'projects-list-filter form-control', spellcheck: false = render 'shared/projects/dropdown' - if can? current_user, :create_projects, @group = link_to new_project_path(namespace_id: @group.id), class: 'btn btn-new pull-right' do diff --git a/app/views/shared/projects/_dropdown.html.haml b/app/views/shared/projects/_dropdown.html.haml index c19697802ce..7e5c00e5608 100644 --- a/app/views/shared/projects/_dropdown.html.haml +++ b/app/views/shared/projects/_dropdown.html.haml @@ -1,4 +1,5 @@ - @sort ||= sort_value_recently_updated +- name = params[:name] - personal = params[:personal] - archived = params[:archived] - shared = params[:shared] @@ -11,32 +12,32 @@ Sort by - projects_sort_options_hash.each do |value, title| %li - = link_to filter_projects_path(namespace_id: namespace_id, sort: value, archived: archived, personal: personal), class: ("is-active" if @sort == value) do + = link_to filter_projects_path(namespace_id: namespace_id, sort: value, archived: archived, personal: personal, name: name), class: ("is-active" if @sort == value) do = title %li.divider %li - = link_to filter_projects_path(namespace_id: namespace_id, sort: @sort, archived: nil), class: ("is-active" unless params[:archived].present?) do + = link_to filter_projects_path(namespace_id: namespace_id, sort: @sort, archived: nil, name: name), class: ("is-active" unless params[:archived].present?) do Hide archived projects %li - = link_to filter_projects_path(namespace_id: namespace_id, sort: @sort, archived: true), class: ("is-active" if params[:archived].present?) do + = link_to filter_projects_path(namespace_id: namespace_id, sort: @sort, archived: true, name: name), class: ("is-active" if params[:archived].present?) do Show archived projects - if current_user %li.divider %li - = link_to filter_projects_path(namespace_id: namespace_id, sort: @sort, personal: nil), class: ("is-active" unless personal.present?) do + = link_to filter_projects_path(namespace_id: namespace_id, sort: @sort, personal: nil, name: name), class: ("is-active" unless personal.present?) do Owned by anyone %li - = link_to filter_projects_path(namespace_id: namespace_id, sort: @sort, personal: true), class: ("is-active" if personal.present?) do + = link_to filter_projects_path(namespace_id: namespace_id, sort: @sort, personal: true, name: name), class: ("is-active" if personal.present?) do Owned by me - if @group && @group.shared_projects.present? %li.divider %li - = link_to filter_projects_path(namespace_id: namespace_id, sort: @sort, shared: nil), class: ("is-active" unless shared.present?) do + = link_to filter_projects_path(namespace_id: namespace_id, sort: @sort, shared: nil, name: name), class: ("is-active" unless shared.present?) do All projects %li - = link_to filter_projects_path(namespace_id: namespace_id, sort: @sort, shared: 0), class: ("is-active" if shared == '0') do + = link_to filter_projects_path(namespace_id: namespace_id, sort: @sort, shared: 0, name: name), class: ("is-active" if shared == '0') do Hide shared projects %li - = link_to filter_projects_path(namespace_id: namespace_id, sort: @sort, shared: 1), class: ("is-active" if shared == '1') do + = link_to filter_projects_path(namespace_id: namespace_id, sort: @sort, shared: 1, name: name), class: ("is-active" if shared == '1') do Hide group projects diff --git a/app/views/shared/projects/_filter_fields.html.haml b/app/views/shared/projects/_filter_fields.html.haml new file mode 100644 index 00000000000..26362ad1eb7 --- /dev/null +++ b/app/views/shared/projects/_filter_fields.html.haml @@ -0,0 +1,11 @@ +- if params[:sort].present? + = hidden_field_tag :sort, params[:sort] + +- if params[:personal].present? + = hidden_field_tag :personal, params[:personal] + +- if params[:archived].present? + = hidden_field_tag :archived, params[:archived] + +- if params[:visibility_level].present? + = hidden_field_tag :visibility_level, params[:visibility_level] diff --git a/changelogs/unreleased/dashboard-filter-search-keep-params.yml b/changelogs/unreleased/dashboard-filter-search-keep-params.yml new file mode 100644 index 00000000000..a140715b7a2 --- /dev/null +++ b/changelogs/unreleased/dashboard-filter-search-keep-params.yml @@ -0,0 +1,4 @@ +--- +title: Dashboard project search keeps selected sort & filters +merge_request: +author: diff --git a/spec/features/dashboard/archived_projects_spec.rb b/spec/features/dashboard/archived_projects_spec.rb index 038c1641be9..f33bcbb5318 100644 --- a/spec/features/dashboard/archived_projects_spec.rb +++ b/spec/features/dashboard/archived_projects_spec.rb @@ -25,4 +25,19 @@ RSpec.describe 'Dashboard Archived Project', feature: true do expect(page).to have_link(project.name) expect(page).to have_link(archived_project.name) end + + it 'searchs archived projects', :js do + click_button 'Last updated' + click_link 'Show archived projects' + + expect(page).to have_link(project.name) + expect(page).to have_link(archived_project.name) + + fill_in 'project-filter-form-field', with: archived_project.name + + find('#project-filter-form-field').native.send_keys :return + + expect(page).not_to have_link(project.name) + expect(page).to have_link(archived_project.name) + end end -- cgit v1.2.1 From 14873c837b1baefe90df8cf38e117c8336f66040 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Wed, 1 Mar 2017 16:54:13 +0000 Subject: Fixed incorrect empty state showing --- app/views/dashboard/projects/index.html.haml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/dashboard/projects/index.html.haml b/app/views/dashboard/projects/index.html.haml index f0adbad8412..4b095f52643 100644 --- a/app/views/dashboard/projects/index.html.haml +++ b/app/views/dashboard/projects/index.html.haml @@ -6,13 +6,13 @@ .user-callout{ 'callout-svg' => custom_icon('icon_customization') } -- if @projects.any? || params[:filter_projects] +- if @projects.any? || params[:name] = render 'dashboard/projects_head' - if @last_push = render "events/event_last_push", event: @last_push -- if @projects.any? || params[:filter_projects] +- if @projects.any? || params[:name] = render 'projects' - else = render "zero_authorized_projects" -- cgit v1.2.1 From 8fd5aeee7fee7506c44674d61f794ff8685b90e6 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Wed, 1 Mar 2017 16:57:11 +0000 Subject: Fixed filter_projects param still used in group controller --- app/controllers/groups_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 7ed54479599..15db5b7762d 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -108,7 +108,7 @@ class GroupsController < Groups::ApplicationController @projects = @projects.sorted_by_activity @projects = filter_projects(@projects) @projects = @projects.sort(@sort = params[:sort]) - @projects = @projects.page(params[:page]) if params[:filter_projects].blank? + @projects = @projects.page(params[:page]) if params[:name].blank? end def authorize_create_group! -- cgit v1.2.1 From ba4a2c546982b1b615be5ed9c8b96361de3f3545 Mon Sep 17 00:00:00 2001 From: Nick Thomas Date: Mon, 20 Feb 2017 21:02:24 +0000 Subject: Add KUBE_CA_PEM_FILE, deprecate KUBE_CA_PEM --- app/models/project_services/kubernetes_service.rb | 7 ++++++- .../unreleased/add-kube-ca-pem-file-deprecate-kube-ca-pem.yml | 4 ++++ doc/user/project/integrations/kubernetes.md | 3 ++- spec/models/project_services/kubernetes_service_spec.rb | 6 ++++++ 4 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 changelogs/unreleased/add-kube-ca-pem-file-deprecate-kube-ca-pem.yml diff --git a/app/models/project_services/kubernetes_service.rb b/app/models/project_services/kubernetes_service.rb index 9819e723fe8..f2e1c906dac 100644 --- a/app/models/project_services/kubernetes_service.rb +++ b/app/models/project_services/kubernetes_service.rb @@ -94,7 +94,12 @@ class KubernetesService < DeploymentService { key: 'KUBE_TOKEN', value: token, public: false }, { key: 'KUBE_NAMESPACE', value: namespace, public: true } ] - variables << { key: 'KUBE_CA_PEM', value: ca_pem, public: true } if ca_pem.present? + + if ca_pem.present? + variables << { key: 'KUBE_CA_PEM', value: ca_pem, public: true } + variables << { key: 'KUBE_CA_PEM_FILE', value: ca_pem, public: true, file: true } + end + variables end diff --git a/changelogs/unreleased/add-kube-ca-pem-file-deprecate-kube-ca-pem.yml b/changelogs/unreleased/add-kube-ca-pem-file-deprecate-kube-ca-pem.yml new file mode 100644 index 00000000000..1ae1e3c7a7a --- /dev/null +++ b/changelogs/unreleased/add-kube-ca-pem-file-deprecate-kube-ca-pem.yml @@ -0,0 +1,4 @@ +--- +title: Add KUBE_CA_PEM_FILE, deprecate KUBE_CA_PEM +merge_request: 9398 +author: diff --git a/doc/user/project/integrations/kubernetes.md b/doc/user/project/integrations/kubernetes.md index cc67e667472..2a890acde4d 100644 --- a/doc/user/project/integrations/kubernetes.md +++ b/doc/user/project/integrations/kubernetes.md @@ -49,7 +49,8 @@ GitLab CI build environment: - `KUBE_URL` - equal to the API URL - `KUBE_TOKEN` - `KUBE_NAMESPACE` -- `KUBE_CA_PEM` - only if a custom CA bundle was specified +- `KUBE_CA_PEM_FILE` - only present if a custom CA bundle was specified. Path to a file containing PEM data. +- `KUBE_CA_PEM` (deprecated)- only if a custom CA bundle was specified. Raw PEM data. ## Web terminals diff --git a/spec/models/project_services/kubernetes_service_spec.rb b/spec/models/project_services/kubernetes_service_spec.rb index 24356447fed..585c899cdf9 100644 --- a/spec/models/project_services/kubernetes_service_spec.rb +++ b/spec/models/project_services/kubernetes_service_spec.rb @@ -163,6 +163,12 @@ describe KubernetesService, models: true, caching: true do { key: 'KUBE_CA_PEM', value: 'CA PEM DATA', public: true } ) end + + it 'sets KUBE_CA_PEM_FILE' do + expect(subject.predefined_variables).to include( + { key: 'KUBE_CA_PEM_FILE', value: 'CA PEM DATA', public: true, file: true } + ) + end end describe '#terminals' do -- cgit v1.2.1 From 5bb6a85b902c6096970d6c82bb63cae7985e55e8 Mon Sep 17 00:00:00 2001 From: Dmitriy Zaporozhets Date: Fri, 3 Mar 2017 12:13:03 +0200 Subject: Refactor projects filtering by name Reuse same search form and behavior for dashboard#projects, group#projects and admin#projects. Repsect all other options like sorting, personal filter when search projects by name. Create FilterableList JS class to handle identical behaviour of projects and groups lists. This change also makes filtering and sorting availabe on explore#projects and explore#groups no matter if you are logged in or not. Signed-off-by: Dmitriy Zaporozhets --- app/assets/javascripts/dispatcher.js.es6 | 13 +++- app/assets/javascripts/filterable_list.js | 47 +++++++++++++ app/assets/javascripts/groups_list.js | 44 ++---------- app/assets/javascripts/projects_list.js | 63 ++++------------- app/assets/stylesheets/pages/search.scss | 6 +- app/controllers/admin/projects_controller.rb | 9 +++ app/helpers/explore_helper.rb | 7 +- app/views/admin/projects/_projects.html.haml | 32 +++++++++ app/views/admin/projects/index.html.haml | 78 ++++++---------------- app/views/dashboard/_groups_head.html.haml | 3 +- app/views/dashboard/_projects_head.html.haml | 4 +- app/views/dashboard/projects/index.html.haml | 1 - app/views/explore/groups/_nav.html.haml | 8 +++ app/views/explore/groups/index.html.haml | 2 +- app/views/explore/projects/_nav.html.haml | 27 +++++--- app/views/explore/projects/index.html.haml | 7 +- app/views/groups/show.html.haml | 4 +- app/views/shared/groups/_search_form.html.haml | 2 + app/views/shared/projects/_dropdown.html.haml | 21 +++--- app/views/shared/projects/_filter_fields.html.haml | 11 --- app/views/shared/projects/_list.html.haml | 5 +- app/views/shared/projects/_search_form.html.haml | 23 +++++++ features/steps/project/fork.rb | 2 +- 23 files changed, 212 insertions(+), 207 deletions(-) create mode 100644 app/assets/javascripts/filterable_list.js create mode 100644 app/views/admin/projects/_projects.html.haml create mode 100644 app/views/explore/groups/_nav.html.haml create mode 100644 app/views/shared/groups/_search_form.html.haml delete mode 100644 app/views/shared/projects/_filter_fields.html.haml create mode 100644 app/views/shared/projects/_search_form.html.haml diff --git a/app/assets/javascripts/dispatcher.js.es6 b/app/assets/javascripts/dispatcher.js.es6 index fc25122aedc..ef5785b5532 100644 --- a/app/assets/javascripts/dispatcher.js.es6 +++ b/app/assets/javascripts/dispatcher.js.es6 @@ -36,6 +36,7 @@ /* global Shortcuts */ import GroupsList from './groups_list'; +import ProjectsList from './projects_list'; const ShortcutsBlob = require('./shortcuts_blob'); const UserCallout = require('./user_callout'); @@ -98,6 +99,14 @@ const UserCallout = require('./user_callout'); case 'dashboard:todos:index': new gl.Todos(); break; + case 'dashboard:projects:index': + case 'dashboard:projects:starred': + case 'explore:projects:index': + case 'explore:projects:trending': + case 'explore:projects:starred': + case 'admin:projects:index': + new ProjectsList(); + break; case 'dashboard:groups:index': case 'explore:groups:index': new GroupsList(); @@ -163,9 +172,6 @@ const UserCallout = require('./user_callout'); case 'dashboard:activity': new gl.Activities(); break; - case 'dashboard:projects:starred': - new gl.Activities(); - break; case 'projects:commit:show': new Commit(); new gl.Diff(); @@ -208,6 +214,7 @@ const UserCallout = require('./user_callout'); shortcut_handler = new ShortcutsNavigation(); new NotificationsForm(); new NotificationsDropdown(); + new ProjectsList(); break; case 'groups:group_members:index': new gl.MemberExpirationDate(); diff --git a/app/assets/javascripts/filterable_list.js b/app/assets/javascripts/filterable_list.js new file mode 100644 index 00000000000..f498c3ea973 --- /dev/null +++ b/app/assets/javascripts/filterable_list.js @@ -0,0 +1,47 @@ +/** + * 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; + this.listFilterElement = filter; + this.listHolderElement = holder; + + this.initSearch(); + } + + initSearch() { + this.debounceFilter = _.debounce(this.filterResults.bind(this), 500); + + this.listFilterElement.removeEventListener('input', this.debounceFilter); + this.listFilterElement.addEventListener('input', this.debounceFilter); + } + + filterResults() { + const form = this.filterForm; + const filterUrl = `${form.getAttribute('action')}?${$(form).serialize()}`; + + $(this.listHolderElement).fadeTo(250, 0.5); + + return $.ajax({ + url: form.getAttribute('action'), + data: $(form).serialize(), + type: 'GET', + dataType: 'json', + context: this, + complete() { + $(this.listHolderElement).fadeTo(250, 1); + }, + success(data) { + this.listHolderElement.innerHTML = data.html; + + // Change url so if user reload a page - search results are saved + return window.history.replaceState({ + page: filterUrl, + + }, document.title, filterUrl); + }, + }); + } +} diff --git a/app/assets/javascripts/groups_list.js b/app/assets/javascripts/groups_list.js index 0ef81e49444..49b29affaa5 100644 --- a/app/assets/javascripts/groups_list.js +++ b/app/assets/javascripts/groups_list.js @@ -1,47 +1,15 @@ +import FilterableList from './filterable_list'; + /** - * Based on project list search. * Makes search request for groups when user types a value in the search input. * Updates the html content of the page with the received one. */ export default class GroupsList { constructor() { - this.groupsListFilterElement = document.querySelector('.js-groups-list-filter'); - this.groupsListHolderElement = document.querySelector('.js-groups-list-holder'); - - this.initSearch(); - } - - initSearch() { - this.debounceFilter = _.debounce(this.filterResults.bind(this), 500); - - this.groupsListFilterElement.removeEventListener('input', this.debounceFilter); - this.groupsListFilterElement.addEventListener('input', this.debounceFilter); - } - - filterResults() { - const form = document.querySelector('form#group-filter-form'); - const groupFilterUrl = `${form.getAttribute('action')}?${$(form).serialize()}`; - - $(this.groupsListHolderElement).fadeTo(250, 0.5); - - return $.ajax({ - url: form.getAttribute('action'), - data: $(form).serialize(), - type: 'GET', - dataType: 'json', - context: this, - complete() { - $(this.groupsListHolderElement).fadeTo(250, 1); - }, - success(data) { - this.groupsListHolderElement.innerHTML = data.html; - - // Change url so if user reload a page - search results are saved - return window.history.replaceState({ - page: groupFilterUrl, + var form = document.querySelector('form#group-filter-form'); + var filter = document.querySelector('.js-groups-list-filter'); + var holder = document.querySelector('.js-groups-list-holder'); - }, document.title, groupFilterUrl); - }, - }); + new FilterableList(form, filter, holder); } } diff --git a/app/assets/javascripts/projects_list.js b/app/assets/javascripts/projects_list.js index acdf9b7eb5a..383c2815457 100644 --- a/app/assets/javascripts/projects_list.js +++ b/app/assets/javascripts/projects_list.js @@ -1,50 +1,15 @@ -/* eslint-disable func-names, space-before-function-paren, object-shorthand, quotes, no-var, one-var, one-var-declaration-per-line, prefer-arrow-callback, consistent-return, no-unused-vars, camelcase, prefer-template, comma-dangle, max-len */ +import FilterableList from './filterable_list'; -(function() { - window.ProjectsList = { - init: function() { - $(".projects-list-filter").off('keyup'); - this.initSearch(); - return this.initPagination(); - }, - initSearch: function() { - var debounceFilter, projectsListFilter; - projectsListFilter = $('.projects-list-filter'); - debounceFilter = _.debounce(window.ProjectsList.filterResults, 500); - return projectsListFilter.on('keyup', function(e) { - if (projectsListFilter.val() !== '') { - return debounceFilter(); - } - }); - }, - filterResults: function() { - var form, project_filter_url, search; - $('.projects-list-holder').fadeTo(250, 0.5); - form = null; - form = $("form#project-filter-form"); - search = $(".projects-list-filter").val(); - project_filter_url = form.attr('action') + '?' + form.serialize(); - return $.ajax({ - type: "GET", - url: form.attr('action'), - data: form.serialize(), - complete: function() { - return $('.projects-list-holder').fadeTo(250, 1); - }, - success: function(data) { - $('.projects-list-holder').replaceWith(data.html); - return history.replaceState({ - page: project_filter_url - // Change url so if user reload a page - search results are saved - }, document.title, project_filter_url); - }, - dataType: "json" - }); - }, - initPagination: function() { - return $('.projects-list-holder .pagination').on('ajax:success', function(e, data) { - return $('.projects-list-holder').replaceWith(data.html); - }); - } - }; -}).call(window); +/** + * Makes search request for projects when user types a value in the search input. + * Updates the html content of the page with the received one. + */ +export default class ProjectsList { + constructor() { + var form = document.querySelector('form#project-filter-form'); + var filter = document.querySelector('.js-projects-list-filter'); + var holder = document.querySelector('.js-projects-list-holder'); + + new FilterableList(form, filter, holder); + } +} diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss index 88ea92c5afb..543d2ece3df 100644 --- a/app/assets/stylesheets/pages/search.scss +++ b/app/assets/stylesheets/pages/search.scss @@ -182,7 +182,8 @@ input[type="checkbox"]:hover { display: flex; } - .search-field-holder { + .search-field-holder, + .project-filter-form { -webkit-flex: 1 0 auto; flex: 1 0 auto; position: relative; @@ -201,7 +202,8 @@ input[type="checkbox"]:hover { pointer-events: none; } - .search-text-input { + .search-text-input, + .project-filter-form-field { padding-left: $gl-padding + 15px; padding-right: $gl-padding + 15px; } diff --git a/app/controllers/admin/projects_controller.rb b/app/controllers/admin/projects_controller.rb index 39c8c6d8a0c..daecfc832bf 100644 --- a/app/controllers/admin/projects_controller.rb +++ b/app/controllers/admin/projects_controller.rb @@ -14,6 +14,15 @@ class Admin::ProjectsController < Admin::ApplicationController @projects = @projects.search(params[:name]) if params[:name].present? @projects = @projects.sort(@sort = params[:sort]) @projects = @projects.includes(:namespace).order("namespaces.path, projects.name ASC").page(params[:page]) + + respond_to do |format| + format.html + format.json do + render json: { + html: view_to_html_string("admin/projects/_projects", locals: { projects: @projects }) + } + end + end end def show diff --git a/app/helpers/explore_helper.rb b/app/helpers/explore_helper.rb index bbcb52f7eaf..cb9bb33f492 100644 --- a/app/helpers/explore_helper.rb +++ b/app/helpers/explore_helper.rb @@ -1,11 +1,16 @@ module ExploreHelper def filter_projects_path(options = {}) exist_opts = { - sort: params[:sort], + sort: params[:sort] || @sort, scope: params[:scope], group: params[:group], tag: params[:tag], visibility_level: params[:visibility_level], + name: params[:name], + personal: params[:personal], + archived: params[:archived], + shared: params[:shared], + namespace_id: params[:namespace_id], } options = exist_opts.merge(options) diff --git a/app/views/admin/projects/_projects.html.haml b/app/views/admin/projects/_projects.html.haml new file mode 100644 index 00000000000..c1a9f8d6ddd --- /dev/null +++ b/app/views/admin/projects/_projects.html.haml @@ -0,0 +1,32 @@ +.js-projects-list-holder + - if @projects.any? + %ul.projects-list.content-list + - @projects.each_with_index do |project| + %li.project-row + .controls + - if project.archived + %span.label.label-warning archived + %span.badge + = storage_counter(project.statistics.storage_size) + = link_to 'Edit', edit_namespace_project_path(project.namespace, project), id: "edit_#{dom_id(project)}", class: "btn" + = link_to 'Delete', [project.namespace.becomes(Namespace), project], data: { confirm: remove_project_message(project) }, method: :delete, class: "btn btn-remove" + .title + = link_to [:admin, project.namespace.becomes(Namespace), project] do + .dash-project-avatar + .avatar-container.s40 + = project_icon(project, alt: '', class: 'avatar project-avatar s40') + %span.project-full-name + %span.namespace-name + - if project.namespace + = project.namespace.human_name + \/ + %span.project-name.filter-title + = project.name + + - if project.description.present? + .description + = markdown_field(project, :description) + + = paginate @projects, theme: 'gitlab' + - else + .nothing-here-block No projects found diff --git a/app/views/admin/projects/index.html.haml b/app/views/admin/projects/index.html.haml index 121662a2ea6..3301f55b8a8 100644 --- a/app/views/admin/projects/index.html.haml +++ b/app/views/admin/projects/index.html.haml @@ -7,33 +7,24 @@ %div{ class: container_class } .top-area .prepend-top-default - = form_tag admin_projects_path, method: :get do |f| - = render "shared/projects/filter_fields" - .search-holder - .search-field-holder - = search_field_tag :name, params[:name], class: "form-control search-text-input js-search-input", id: "dashboard_search", autofocus: true, spellcheck: false, placeholder: 'Search by name' - - - if params[:visibility_level].present? - = hidden_field_tag 'visibility_level', params[:visibility_level] - - = icon("search", class: "search-icon") - - .dropdown - - toggle_text = 'Namespace' - - if params[:namespace_id].present? - = hidden_field_tag :namespace_id, params[:namespace_id] - - namespace = Namespace.find(params[:namespace_id]) - - toggle_text = "#{namespace.kind}: #{namespace.full_path}" - = dropdown_toggle(toggle_text, { toggle: 'dropdown' }, { toggle_class: 'js-namespace-select large' }) - .dropdown-menu.dropdown-select.dropdown-menu-align-right - = dropdown_title('Namespaces') - = dropdown_filter("Search for Namespace") - = dropdown_content - = dropdown_loading - = render 'shared/projects/dropdown' - = link_to new_project_path, class: 'btn btn-new' do - New Project - = button_tag "Search", class: "btn btn-primary btn-search hide" + .search-holder + = render 'shared/projects/search_form', autofocus: true, icon: true + .dropdown + - toggle_text = 'Namespace' + - if params[:namespace_id].present? + = hidden_field_tag :namespace_id, params[:namespace_id] + - namespace = Namespace.find(params[:namespace_id]) + - toggle_text = "#{namespace.kind}: #{namespace.full_path}" + = dropdown_toggle(toggle_text, { toggle: 'dropdown' }, { toggle_class: 'js-namespace-select large' }) + .dropdown-menu.dropdown-select.dropdown-menu-align-right + = dropdown_title('Namespaces') + = dropdown_filter("Search for Namespace") + = dropdown_content + = dropdown_loading + = render 'shared/projects/dropdown' + = link_to new_project_path, class: 'btn btn-new' do + New Project + = button_tag "Search", class: "btn btn-primary btn-search hide" %ul.nav-links - opts = params[:visibility_level].present? ? {} : { page: admin_projects_path } @@ -51,35 +42,4 @@ = link_to admin_projects_path(visibility_level: Gitlab::VisibilityLevel::PUBLIC) do Public - .projects-list-holder - - if @projects.any? - %ul.projects-list.content-list - - @projects.each_with_index do |project| - %li.project-row - .controls - - if project.archived - %span.label.label-warning archived - %span.badge - = storage_counter(project.statistics.storage_size) - = link_to 'Edit', edit_namespace_project_path(project.namespace, project), id: "edit_#{dom_id(project)}", class: "btn" - = link_to 'Delete', [project.namespace.becomes(Namespace), project], data: { confirm: remove_project_message(project) }, method: :delete, class: "btn btn-remove" - .title - = link_to [:admin, project.namespace.becomes(Namespace), project] do - .dash-project-avatar - .avatar-container.s40 - = project_icon(project, alt: '', class: 'avatar project-avatar s40') - %span.project-full-name - %span.namespace-name - - if project.namespace - = project.namespace.human_name - \/ - %span.project-name.filter-title - = project.name - - - if project.description.present? - .description - = markdown_field(project, :description) - - = paginate @projects, theme: 'gitlab' - - else - .nothing-here-block No projects found + = render 'projects' diff --git a/app/views/dashboard/_groups_head.html.haml b/app/views/dashboard/_groups_head.html.haml index c6d5937a3c3..13eaba41f4c 100644 --- a/app/views/dashboard/_groups_head.html.haml +++ b/app/views/dashboard/_groups_head.html.haml @@ -7,8 +7,7 @@ = link_to explore_groups_path, title: 'Explore groups' do Explore Groups .nav-controls - = form_tag request.path, method: :get, class: 'group-filter-form', id: 'group-filter-form' do |f| - = search_field_tag :filter_groups, params[:filter_groups], placeholder: 'Filter by name...', class: 'group-filter-form-field form-control input-short js-groups-list-filter', spellcheck: false, id: 'group-filter-form-field', tabindex: "2" + = render 'shared/groups/search_form' = render 'shared/groups/dropdown' - if current_user.can_create_group? = link_to new_group_path, class: "btn btn-new" do diff --git a/app/views/dashboard/_projects_head.html.haml b/app/views/dashboard/_projects_head.html.haml index 795ad9f33c1..600ee63a5c0 100644 --- a/app/views/dashboard/_projects_head.html.haml +++ b/app/views/dashboard/_projects_head.html.haml @@ -13,9 +13,7 @@ Explore projects .nav-controls - = form_tag request.fullpath, method: :get, class: 'project-filter-form', id: 'project-filter-form' do |f| - = render "shared/projects/filter_fields" - = search_field_tag :name, params[:name], placeholder: 'Filter by name...', class: 'project-filter-form-field form-control input-short projects-list-filter', spellcheck: false, id: 'project-filter-form-field', tabindex: "2" + = render 'shared/projects/search_form' = render 'shared/projects/dropdown' - if current_user.can_create_project? = link_to new_project_path, class: 'btn btn-new' do diff --git a/app/views/dashboard/projects/index.html.haml b/app/views/dashboard/projects/index.html.haml index 4b095f52643..eef794dbd51 100644 --- a/app/views/dashboard/projects/index.html.haml +++ b/app/views/dashboard/projects/index.html.haml @@ -5,7 +5,6 @@ - header_title "Projects", dashboard_projects_path .user-callout{ 'callout-svg' => custom_icon('icon_customization') } - - if @projects.any? || params[:name] = render 'dashboard/projects_head' diff --git a/app/views/explore/groups/_nav.html.haml b/app/views/explore/groups/_nav.html.haml new file mode 100644 index 00000000000..c8d95b52156 --- /dev/null +++ b/app/views/explore/groups/_nav.html.haml @@ -0,0 +1,8 @@ +.top-area + %ul.nav-links + = nav_link(page: explore_groups_path) do + = link_to explore_groups_path do + Explore Groups + .nav-controls + = render 'shared/groups/search_form' + = render 'shared/groups/dropdown' diff --git a/app/views/explore/groups/index.html.haml b/app/views/explore/groups/index.html.haml index 7f1bacc91cb..8374f5a009f 100644 --- a/app/views/explore/groups/index.html.haml +++ b/app/views/explore/groups/index.html.haml @@ -5,7 +5,7 @@ = render 'dashboard/groups_head' - else = render 'explore/head' - + = render 'nav' - if @groups.present? = render 'groups' diff --git a/app/views/explore/projects/_nav.html.haml b/app/views/explore/projects/_nav.html.haml index 614b5431779..e0a2a1e9c96 100644 --- a/app/views/explore/projects/_nav.html.haml +++ b/app/views/explore/projects/_nav.html.haml @@ -1,10 +1,17 @@ -%ul.nav-links - = nav_link(page: [trending_explore_projects_path, explore_root_path]) do - = link_to trending_explore_projects_path do - Trending - = nav_link(page: starred_explore_projects_path) do - = link_to starred_explore_projects_path do - Most stars - = nav_link(page: explore_projects_path) do - = link_to explore_projects_path do - All +.top-area + %ul.nav-links + = nav_link(page: [trending_explore_projects_path, explore_root_path]) do + = link_to trending_explore_projects_path do + Trending + = nav_link(page: starred_explore_projects_path) do + = link_to starred_explore_projects_path do + Most stars + = nav_link(page: explore_projects_path) do + = link_to explore_projects_path do + All + + .nav-controls + - unless current_user + = render 'shared/projects/search_form' + = render 'shared/projects/dropdown' + = render 'filter' diff --git a/app/views/explore/projects/index.html.haml b/app/views/explore/projects/index.html.haml index 42b50481b9d..ec461755103 100644 --- a/app/views/explore/projects/index.html.haml +++ b/app/views/explore/projects/index.html.haml @@ -6,10 +6,5 @@ - else = render 'explore/head' -.top-area - = render 'explore/projects/nav' - - .nav-controls - = render 'filter' - += render 'explore/projects/nav' = render 'projects', projects: @projects diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml index 5a7b0c1534b..18997baa998 100644 --- a/app/views/groups/show.html.haml +++ b/app/views/groups/show.html.haml @@ -11,9 +11,7 @@ .top-area = render 'groups/show_nav' .nav-controls - = form_tag request.fullpath, method: :get, class: 'project-filter-form', id: 'project-filter-form' do |f| - = render "shared/projects/filter_fields" - = search_field_tag :name, nil, placeholder: 'Filter by name', class: 'projects-list-filter form-control', spellcheck: false + = render 'shared/projects/search_form' = render 'shared/projects/dropdown' - if can? current_user, :create_projects, @group = link_to new_project_path(namespace_id: @group.id), class: 'btn btn-new pull-right' do diff --git a/app/views/shared/groups/_search_form.html.haml b/app/views/shared/groups/_search_form.html.haml new file mode 100644 index 00000000000..ad7a7faedf1 --- /dev/null +++ b/app/views/shared/groups/_search_form.html.haml @@ -0,0 +1,2 @@ += form_tag request.path, method: :get, class: 'group-filter-form', id: 'group-filter-form' do |f| + = search_field_tag :filter_groups, params[:filter_groups], placeholder: 'Filter by name...', class: 'group-filter-form-field form-control input-short js-groups-list-filter', spellcheck: false, id: 'group-filter-form-field', tabindex: "2" diff --git a/app/views/shared/projects/_dropdown.html.haml b/app/views/shared/projects/_dropdown.html.haml index 7e5c00e5608..2d25b8aad62 100644 --- a/app/views/shared/projects/_dropdown.html.haml +++ b/app/views/shared/projects/_dropdown.html.haml @@ -1,9 +1,4 @@ - @sort ||= sort_value_recently_updated -- name = params[:name] -- personal = params[:personal] -- archived = params[:archived] -- shared = params[:shared] -- namespace_id = params[:namespace_id] .dropdown - toggle_text = projects_sort_options_hash[@sort] = dropdown_toggle(toggle_text, { toggle: 'dropdown' }, { id: 'sort-projects-dropdown' }) @@ -12,32 +7,32 @@ Sort by - projects_sort_options_hash.each do |value, title| %li - = link_to filter_projects_path(namespace_id: namespace_id, sort: value, archived: archived, personal: personal, name: name), class: ("is-active" if @sort == value) do + = link_to filter_projects_path(sort: value), class: ("is-active" if @sort == value) do = title %li.divider %li - = link_to filter_projects_path(namespace_id: namespace_id, sort: @sort, archived: nil, name: name), class: ("is-active" unless params[:archived].present?) do + = link_to filter_projects_path(archived: nil), class: ("is-active" unless params[:archived].present?) do Hide archived projects %li - = link_to filter_projects_path(namespace_id: namespace_id, sort: @sort, archived: true, name: name), class: ("is-active" if params[:archived].present?) do + = link_to filter_projects_path(archived: true), class: ("is-active" if params[:archived].present?) do Show archived projects - if current_user %li.divider %li - = link_to filter_projects_path(namespace_id: namespace_id, sort: @sort, personal: nil, name: name), class: ("is-active" unless personal.present?) do + = link_to filter_projects_path(personal: nil), class: ("is-active" unless params[:personal].present?) do Owned by anyone %li - = link_to filter_projects_path(namespace_id: namespace_id, sort: @sort, personal: true, name: name), class: ("is-active" if personal.present?) do + = link_to filter_projects_path(personal: true), class: ("is-active" if params[:personal].present?) do Owned by me - if @group && @group.shared_projects.present? %li.divider %li - = link_to filter_projects_path(namespace_id: namespace_id, sort: @sort, shared: nil, name: name), class: ("is-active" unless shared.present?) do + = link_to filter_projects_path(shared: nil), class: ("is-active" unless params[:shared].present?) do All projects %li - = link_to filter_projects_path(namespace_id: namespace_id, sort: @sort, shared: 0, name: name), class: ("is-active" if shared == '0') do + = link_to filter_projects_path(shared: 0), class: ("is-active" if params[:shared] == '0') do Hide shared projects %li - = link_to filter_projects_path(namespace_id: namespace_id, sort: @sort, shared: 1, name: name), class: ("is-active" if shared == '1') do + = link_to filter_projects_path(shared: 1), class: ("is-active" if params[:shared] == '1') do Hide group projects diff --git a/app/views/shared/projects/_filter_fields.html.haml b/app/views/shared/projects/_filter_fields.html.haml deleted file mode 100644 index 26362ad1eb7..00000000000 --- a/app/views/shared/projects/_filter_fields.html.haml +++ /dev/null @@ -1,11 +0,0 @@ -- if params[:sort].present? - = hidden_field_tag :sort, params[:sort] - -- if params[:personal].present? - = hidden_field_tag :personal, params[:personal] - -- if params[:archived].present? - = hidden_field_tag :archived, params[:archived] - -- if params[:visibility_level].present? - = hidden_field_tag :visibility_level, params[:visibility_level] diff --git a/app/views/shared/projects/_list.html.haml b/app/views/shared/projects/_list.html.haml index 3a9dd37dc7d..c57282c5742 100644 --- a/app/views/shared/projects/_list.html.haml +++ b/app/views/shared/projects/_list.html.haml @@ -8,7 +8,7 @@ - show_last_commit_as_description = false unless local_assigns[:show_last_commit_as_description] == true - remote = false unless local_assigns[:remote] == true -.projects-list-holder +.js-projects-list-holder - if projects.any? %ul.projects-list.content-list - projects.each_with_index do |project, i| @@ -25,6 +25,3 @@ = paginate(projects, remote: remote, theme: "gitlab") if projects.respond_to? :total_pages - else .nothing-here-block No projects found - -:javascript - ProjectsList.init(); diff --git a/app/views/shared/projects/_search_form.html.haml b/app/views/shared/projects/_search_form.html.haml new file mode 100644 index 00000000000..b89194bcc67 --- /dev/null +++ b/app/views/shared/projects/_search_form.html.haml @@ -0,0 +1,23 @@ += form_tag filter_projects_path, method: :get, class: 'project-filter-form', id: 'project-filter-form' do |f| + = search_field_tag :name, params[:name], + placeholder: 'Filter by name...', + class: 'project-filter-form-field form-control input-short js-projects-list-filter', + spellcheck: false, + id: 'project-filter-form-field', + tabindex: "2", + autofocus: local_assigns[:autofocus] + + - if local_assigns[:icon] + = icon("search", class: "search-icon") + + - if params[:sort].present? + = hidden_field_tag :sort, params[:sort] + + - if params[:personal].present? + = hidden_field_tag :personal, params[:personal] + + - if params[:archived].present? + = hidden_field_tag :archived, params[:archived] + + - if params[:visibility_level].present? + = hidden_field_tag :visibility_level, params[:visibility_level] diff --git a/features/steps/project/fork.rb b/features/steps/project/fork.rb index 9a6c04fba7a..79db9728227 100644 --- a/features/steps/project/fork.rb +++ b/features/steps/project/fork.rb @@ -56,7 +56,7 @@ class Spinach::Features::ProjectFork < Spinach::FeatureSteps end step 'I should see my fork on the list' do - page.within('.projects-list-holder') do + page.within('.js-projects-list-holder') do project = @user.fork_of(@project) expect(page).to have_content("#{project.namespace.human_name} / #{project.name}") end -- cgit v1.2.1 From d13669c98b5b2c15cfff8b65e12ce1ef0911f1cd Mon Sep 17 00:00:00 2001 From: Pawel Chojnacki Date: Fri, 24 Feb 2017 13:18:07 +0100 Subject: Remove remnants of git annex --- app/controllers/profiles/keys_controller.rb | 5 ----- .../1648-remove-remnants-of-git-annex-from-ce.yml | 4 ++++ config/routes/profile.rb | 2 +- doc/ci/variables/README.md | 4 ++-- doc/downgrade_ee_to_ce/README.md | 7 ------- doc/university/README.md | 2 +- doc/university/support/README.md | 1 - .../lfs/manage_large_binaries_with_git_lfs.md | 7 ------- lib/backup/repository.rb | 5 ++--- spec/controllers/profiles/keys_controller_spec.rb | 10 ---------- spec/tasks/gitlab/backup_rake_spec.rb | 21 +-------------------- 11 files changed, 11 insertions(+), 57 deletions(-) create mode 100644 changelogs/unreleased/1648-remove-remnants-of-git-annex-from-ce.yml diff --git a/app/controllers/profiles/keys_controller.rb b/app/controllers/profiles/keys_controller.rb index c8663a3c38e..e4452f46056 100644 --- a/app/controllers/profiles/keys_controller.rb +++ b/app/controllers/profiles/keys_controller.rb @@ -10,11 +10,6 @@ class Profiles::KeysController < Profiles::ApplicationController @key = current_user.keys.find(params[:id]) end - # Back-compat: We need to support this URL since git-annex webapp points to it - def new - redirect_to profile_keys_path - end - def create @key = current_user.keys.new(key_params) diff --git a/changelogs/unreleased/1648-remove-remnants-of-git-annex-from-ce.yml b/changelogs/unreleased/1648-remove-remnants-of-git-annex-from-ce.yml new file mode 100644 index 00000000000..f247fe35439 --- /dev/null +++ b/changelogs/unreleased/1648-remove-remnants-of-git-annex-from-ce.yml @@ -0,0 +1,4 @@ +--- +title: Remove remnants of git annex support. +merge_request: +author: diff --git a/config/routes/profile.rb b/config/routes/profile.rb index 6b91485da9e..07c341999ea 100644 --- a/config/routes/profile.rb +++ b/config/routes/profile.rb @@ -21,7 +21,7 @@ resource :profile, only: [:show, :update] do end end resource :preferences, only: [:show, :update] - resources :keys, only: [:index, :show, :new, :create, :destroy] + resources :keys, only: [:index, :show, :create, :destroy] resources :emails, only: [:index, :create, :destroy] resources :chat_names, only: [:index, :new, :create, :destroy] do collection do diff --git a/doc/ci/variables/README.md b/doc/ci/variables/README.md index 04c0af44237..a9e25187b88 100644 --- a/doc/ci/variables/README.md +++ b/doc/ci/variables/README.md @@ -308,8 +308,8 @@ Running on runner-8a2f473d-project-1796893-concurrent-0 via runner-8a2f473d-mach ++ CI_RUNNER_ID=1337 ++ export CI_RUNNER_DESCRIPTION=shared-runners-manager-1.example.com ++ CI_RUNNER_DESCRIPTION=shared-runners-manager-1.example.com -++ export 'CI_RUNNER_TAGS=shared, docker, linux, ruby, mysql, postgres, mongo, git-annex' -++ CI_RUNNER_TAGS='shared, docker, linux, ruby, mysql, postgres, mongo, git-annex' +++ export 'CI_RUNNER_TAGS=shared, docker, linux, ruby, mysql, postgres, mongo' +++ CI_RUNNER_TAGS='shared, docker, linux, ruby, mysql, postgres, mongo' ++ export CI_REGISTRY=registry.example.com ++ CI_REGISTRY=registry.example.com ++ export CI_DEBUG_TRACE=true diff --git a/doc/downgrade_ee_to_ce/README.md b/doc/downgrade_ee_to_ce/README.md index a6d22e5a04a..fe4b6d73771 100644 --- a/doc/downgrade_ee_to_ce/README.md +++ b/doc/downgrade_ee_to_ce/README.md @@ -15,13 +15,6 @@ Kerberos and Atlassian Crowd are only available on the Enterprise Edition, so you should disable these mechanisms before downgrading and you should provide alternative authentication methods to your users. -### Git Annex - -Git Annex is also only available on the Enterprise Edition. This means that if -you have repositories that use Git Annex to store large files, these files will -no longer be easily available via Git. You should consider migrating these -repositories to use Git LFS before downgrading to the Community Edition. - ### Remove Jenkins CI Service entries from the database The `JenkinsService` class is only available on the Enterprise Edition codebase, diff --git a/doc/university/README.md b/doc/university/README.md index 8d4e7eff115..c1661f0b52b 100644 --- a/doc/university/README.md +++ b/doc/university/README.md @@ -165,7 +165,7 @@ The curriculum is composed of GitLab videos, screencasts, presentations, project #### 3.4. Large Files -1. [Big files in Git (Git LFS, Annex) - Video](https://www.youtube.com/watch?v=DawznUxYDe4) +1. [Big files in Git (Git LFS) - Video](https://www.youtube.com/watch?v=DawznUxYDe4) #### 3.5. LDAP and Active Directory diff --git a/doc/university/support/README.md b/doc/university/support/README.md index ca538ef6dc3..567dadb3b47 100644 --- a/doc/university/support/README.md +++ b/doc/university/support/README.md @@ -167,7 +167,6 @@ Some tickets need specific knowledge or a deep understanding of a particular com Move on to understanding some of GitLab's more advanced features. You can make use of GitLab.com to understand the features from an end-user perspective and then use your own instance to understand setup and configuration of the feature from an Administrative perspective -- Set up and try [Git Annex](https://docs.gitlab.com/ee/workflow/git_annex.html) - Set up and try [Git LFS](https://docs.gitlab.com/ee/workflow/lfs/manage_large_binaries_with_git_lfs.html) - Get to know the [GitLab API](https://docs.gitlab.com/ee/api/README.html), its capabilities and shortcomings - Learn how to [migrate from SVN to Git](https://docs.gitlab.com/ee/workflow/importing/migrating_from_svn.html) diff --git a/doc/workflow/lfs/manage_large_binaries_with_git_lfs.md b/doc/workflow/lfs/manage_large_binaries_with_git_lfs.md index 9cc45065eb2..6adde447975 100644 --- a/doc/workflow/lfs/manage_large_binaries_with_git_lfs.md +++ b/doc/workflow/lfs/manage_large_binaries_with_git_lfs.md @@ -4,13 +4,6 @@ Managing large files such as audio, video and graphics files has always been one of the shortcomings of Git. The general recommendation is to not have Git repositories larger than 1GB to preserve performance. -GitLab already supports [managing large files with git annex](http://docs.gitlab.com/ee/workflow/git_annex.html) -(EE only), however in certain environments it is not always convenient to use -different commands to differentiate between the large files and regular ones. - -Git LFS makes this simpler for the end user by removing the requirement to -learn new commands. - ## How it works Git LFS client talks with the GitLab server over HTTPS. It uses HTTP Basic Authentication diff --git a/lib/backup/repository.rb b/lib/backup/repository.rb index d16d5ba4960..3c4ba5d50e6 100644 --- a/lib/backup/repository.rb +++ b/lib/backup/repository.rb @@ -180,9 +180,8 @@ module Backup return unless Dir.exist?(path) dir_entries = Dir.entries(path) - %w[annex custom_hooks].each do |entry| - yield(entry) if dir_entries.include?(entry) - end + + yield('custom_hooks') if dir_entries.include?('custom_hooks') end def prepare diff --git a/spec/controllers/profiles/keys_controller_spec.rb b/spec/controllers/profiles/keys_controller_spec.rb index f7219690722..61e4fae46fb 100644 --- a/spec/controllers/profiles/keys_controller_spec.rb +++ b/spec/controllers/profiles/keys_controller_spec.rb @@ -3,16 +3,6 @@ require 'spec_helper' describe Profiles::KeysController do let(:user) { create(:user) } - describe '#new' do - before { sign_in(user) } - - it 'redirects to #index' do - get :new - - expect(response).to redirect_to(profile_keys_path) - end - end - describe "#get_keys" do describe "non existant user" do it "does not generally work" do diff --git a/spec/tasks/gitlab/backup_rake_spec.rb b/spec/tasks/gitlab/backup_rake_spec.rb index df8a47893f9..dfbfbd05f43 100644 --- a/spec/tasks/gitlab/backup_rake_spec.rb +++ b/spec/tasks/gitlab/backup_rake_spec.rb @@ -108,7 +108,7 @@ describe 'gitlab:app namespace rake task' do $stdout = orig_stdout end - describe 'backup creation and deletion using annex and custom_hooks' do + describe 'backup creation and deletion using custom_hooks' do let(:project) { create(:project) } let(:user_backup_path) { "repositories/#{project.path_with_namespace}" } @@ -132,25 +132,6 @@ describe 'gitlab:app namespace rake task' do Dir.chdir(@origin_cd) end - context 'project uses git-annex and successfully creates backup' do - let(:filename) { "annex" } - - it 'creates annex.tar and project bundle' do - tar_contents, exit_status = Gitlab::Popen.popen(%W{tar -tvf #{@backup_tar}}) - - expect(exit_status).to eq(0) - expect(tar_contents).to match(user_backup_path) - expect(tar_contents).to match("#{user_backup_path}/annex.tar") - expect(tar_contents).to match("#{user_backup_path}.bundle") - end - - it 'restores files correctly' do - restore_backup - - expect(Dir.entries(File.join(project.repository.path, "annex"))).to include("dummy.txt") - end - end - context 'project uses custom_hooks and successfully creates backup' do let(:filename) { "custom_hooks" } -- cgit v1.2.1 From 1d49d065ed168fcc3653d0d4681485edd524043e Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Fri, 3 Mar 2017 18:03:58 +0000 Subject: Removed empty options from URL --- app/helpers/explore_helper.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/helpers/explore_helper.rb b/app/helpers/explore_helper.rb index cb9bb33f492..7bd212a3ef9 100644 --- a/app/helpers/explore_helper.rb +++ b/app/helpers/explore_helper.rb @@ -13,7 +13,7 @@ module ExploreHelper namespace_id: params[:namespace_id], } - options = exist_opts.merge(options) + options = exist_opts.merge(options).delete_if { |key, value| value.blank? } request_path_with_options(options) end -- cgit v1.2.1 From 0fcb8ce7d66f2d0a923a2de6568116f8e8088028 Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre Date: Fri, 3 Mar 2017 15:44:08 -0300 Subject: Bump health_check gem to version 2.6.0 --- Gemfile | 2 +- Gemfile.lock | 4 ++-- app/controllers/admin/health_check_controller.rb | 2 +- spec/controllers/health_check_controller_spec.rb | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Gemfile b/Gemfile index b21f563940d..a267030eb4c 100644 --- a/Gemfile +++ b/Gemfile @@ -344,7 +344,7 @@ gem 'oauth2', '~> 1.2.0' gem 'paranoia', '~> 2.2' # Health check -gem 'health_check', '~> 2.2.0' +gem 'health_check', '~> 2.6.0' # System information gem 'vmstat', '~> 2.3.0' diff --git a/Gemfile.lock b/Gemfile.lock index 2d7e6f6f0bf..65120df205c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -336,7 +336,7 @@ GEM thor tilt hashie (3.5.5) - health_check (2.2.1) + health_check (2.6.0) rails (>= 4.0) hipchat (1.5.2) httparty @@ -895,7 +895,7 @@ DEPENDENCIES grape-entity (~> 0.6.0) haml_lint (~> 0.21.0) hamlit (~> 2.6.1) - health_check (~> 2.2.0) + health_check (~> 2.6.0) hipchat (~> 1.5.0) html-pipeline (~> 1.11.0) html2text diff --git a/app/controllers/admin/health_check_controller.rb b/app/controllers/admin/health_check_controller.rb index 241c7be0ea1..caf4c138da8 100644 --- a/app/controllers/admin/health_check_controller.rb +++ b/app/controllers/admin/health_check_controller.rb @@ -1,5 +1,5 @@ class Admin::HealthCheckController < Admin::ApplicationController def show - @errors = HealthCheck::Utils.process_checks('standard') + @errors = HealthCheck::Utils.process_checks(['standard']) end end diff --git a/spec/controllers/health_check_controller_spec.rb b/spec/controllers/health_check_controller_spec.rb index cfe18dd4b6c..58c16cc57e6 100644 --- a/spec/controllers/health_check_controller_spec.rb +++ b/spec/controllers/health_check_controller_spec.rb @@ -64,8 +64,8 @@ describe HealthCheckController do context 'when a service is down and an access token is provided' do before do - allow(HealthCheck::Utils).to receive(:process_checks).with('standard').and_return('The server is on fire') - allow(HealthCheck::Utils).to receive(:process_checks).with('email').and_return('Email is on fire') + allow(HealthCheck::Utils).to receive(:process_checks).with(['standard']).and_return('The server is on fire') + allow(HealthCheck::Utils).to receive(:process_checks).with(['email']).and_return('Email is on fire') end it 'supports passing the token in the header' do -- cgit v1.2.1 From f13fbf62bc54301e969951920833cbdb67df3ca5 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Fri, 3 Mar 2017 20:17:37 +0000 Subject: Revert "Merge branch 'tooltip-hide-on-scroll' into 'master'" This reverts merge request !9653 --- app/assets/javascripts/application.js | 4 ---- changelogs/unreleased/tooltip-hide-on-scroll.yml | 4 ---- 2 files changed, 8 deletions(-) delete mode 100644 changelogs/unreleased/tooltip-hide-on-scroll.yml diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index e0ee698a8ff..4c24d35b5bb 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -231,10 +231,6 @@ require('es6-promise').polyfill(); var bootstrapBreakpoint = bp.getBreakpointSize(); var fitSidebarForSize; - $(document).on('scroll', function() { - $('.has-tooltip').tooltip('hide'); - }); - // Set the default path for all cookies to GitLab's root directory Cookies.defaults.path = gon.relative_url_root || '/'; diff --git a/changelogs/unreleased/tooltip-hide-on-scroll.yml b/changelogs/unreleased/tooltip-hide-on-scroll.yml deleted file mode 100644 index cd81d303330..00000000000 --- a/changelogs/unreleased/tooltip-hide-on-scroll.yml +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: Fixed tooltip remaining after scrolling the page -merge_request: -author: -- cgit v1.2.1 From 02504f2f9c8f0316b9235121f9677a998136bfc4 Mon Sep 17 00:00:00 2001 From: Mike Greiling Date: Fri, 10 Feb 2017 00:45:42 -0600 Subject: use deterministic module IDs in production and development --- config/webpack.config.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/config/webpack.config.js b/config/webpack.config.js index 13273902b0e..d06f7733945 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -83,6 +83,10 @@ var config = { assets: true }), new webpack.IgnorePlugin(/moment/, /pikaday/), + // use deterministic module ids in all environments + IS_PRODUCTION ? + new webpack.HashedModuleIdsPlugin() : + new webpack.NamedModulesPlugin(), ], resolve: { -- cgit v1.2.1 From e80fa69895dadfbd5cdc95f7feb9593cfe52e9b6 Mon Sep 17 00:00:00 2001 From: Mike Greiling Date: Wed, 1 Mar 2017 15:47:52 -0600 Subject: update plugin formatting --- config/webpack.config.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/config/webpack.config.js b/config/webpack.config.js index d06f7733945..cbf59276dce 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -82,7 +82,10 @@ var config = { modules: false, assets: true }), + + // prevent pikaday from including moment.js new webpack.IgnorePlugin(/moment/, /pikaday/), + // use deterministic module ids in all environments IS_PRODUCTION ? new webpack.HashedModuleIdsPlugin() : -- cgit v1.2.1 From cb6c036d8942ab24048b9ecebcc6c3fa408c9a3e Mon Sep 17 00:00:00 2001 From: Mike Greiling Date: Wed, 1 Mar 2017 16:01:33 -0600 Subject: create a common.js bundle and include all jQuery plugins --- app/assets/javascripts/application.js | 18 +----------------- app/assets/javascripts/commons/bootstrap.js | 10 ++++++++++ app/assets/javascripts/commons/index.js | 2 ++ app/assets/javascripts/commons/jquery.js | 12 ++++++++++++ app/views/layouts/_head.html.haml | 1 + config/webpack.config.js | 8 +++++++- spec/javascripts/test_bundle.js | 13 +------------ 7 files changed, 34 insertions(+), 30 deletions(-) create mode 100644 app/assets/javascripts/commons/bootstrap.js create mode 100644 app/assets/javascripts/commons/index.js create mode 100644 app/assets/javascripts/commons/jquery.js diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 4c24d35b5bb..8441a335ac0 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -6,23 +6,8 @@ /* global AwardsHandler */ /* global Aside */ -window.$ = window.jQuery = require('jquery'); -require('jquery-ujs'); -require('vendor/jquery.endless-scroll'); -require('vendor/jquery.waitforimages'); -require('vendor/jquery.caret'); -require('vendor/jquery.atwho'); -require('vendor/jquery.scrollTo'); +// common libraries window.Cookies = require('js-cookie'); -require('./autosave'); -require('bootstrap/js/affix'); -require('bootstrap/js/alert'); -require('bootstrap/js/dropdown'); -require('bootstrap/js/modal'); -require('bootstrap/js/tab'); -require('bootstrap/js/transition'); -require('bootstrap/js/tooltip'); -require('select2/select2.js'); window.Pikaday = require('pikaday'); window._ = require('underscore'); window.Dropzone = require('dropzone'); @@ -34,7 +19,6 @@ require('./shortcuts_navigation'); require('./shortcuts_dashboard_navigation'); require('./shortcuts_issuable'); require('./shortcuts_network'); -require('vendor/jquery.nicescroll'); // behaviors require('./behaviors/autosize'); diff --git a/app/assets/javascripts/commons/bootstrap.js b/app/assets/javascripts/commons/bootstrap.js new file mode 100644 index 00000000000..72e43d34a74 --- /dev/null +++ b/app/assets/javascripts/commons/bootstrap.js @@ -0,0 +1,10 @@ +require('./jquery'); + +// twitter bootstrap plugins +require('bootstrap-sass/assets/javascripts/bootstrap/affix'); +require('bootstrap-sass/assets/javascripts/bootstrap/alert'); +require('bootstrap-sass/assets/javascripts/bootstrap/dropdown'); +require('bootstrap-sass/assets/javascripts/bootstrap/modal'); +require('bootstrap-sass/assets/javascripts/bootstrap/tab'); +require('bootstrap-sass/assets/javascripts/bootstrap/transition'); +require('bootstrap-sass/assets/javascripts/bootstrap/tooltip'); diff --git a/app/assets/javascripts/commons/index.js b/app/assets/javascripts/commons/index.js new file mode 100644 index 00000000000..a9226bc1325 --- /dev/null +++ b/app/assets/javascripts/commons/index.js @@ -0,0 +1,2 @@ +require('./jquery'); +require('./bootstrap'); diff --git a/app/assets/javascripts/commons/jquery.js b/app/assets/javascripts/commons/jquery.js new file mode 100644 index 00000000000..9ef415d6a95 --- /dev/null +++ b/app/assets/javascripts/commons/jquery.js @@ -0,0 +1,12 @@ +// jQuery library +window.$ = window.jQuery = require('jquery'); + +// jQuery plugins +require('jquery-ujs'); +require('vendor/jquery.endless-scroll'); +require('vendor/jquery.caret'); +require('vendor/jquery.atwho'); +require('vendor/jquery.scrollTo'); +require('vendor/jquery.nicescroll'); +require('vendor/jquery.waitforimages'); +require('select2/select2.js'); diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml index 302c1794628..29844b1027b 100644 --- a/app/views/layouts/_head.html.haml +++ b/app/views/layouts/_head.html.haml @@ -28,6 +28,7 @@ = stylesheet_link_tag "application", media: "all" = stylesheet_link_tag "print", media: "print" + = javascript_include_tag(*webpack_asset_paths("common")) = javascript_include_tag(*webpack_asset_paths("application")) - if content_for?(:page_specific_javascripts) diff --git a/config/webpack.config.js b/config/webpack.config.js index cbf59276dce..c2e0e86fb78 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -17,6 +17,7 @@ var WEBPACK_REPORT = process.env.WEBPACK_REPORT; var config = { context: path.join(ROOT_PATH, 'app/assets/javascripts'), entry: { + common: './commons/index.js', application: './application.js', blob_edit: './blob_edit/blob_edit_bundle.js', boards: './boards/boards_bundle.js', @@ -90,13 +91,18 @@ var config = { IS_PRODUCTION ? new webpack.HashedModuleIdsPlugin() : new webpack.NamedModulesPlugin(), + + // create a common.js bundle to be loaded on every page + new webpack.optimize.CommonsChunkPlugin({ + name: 'common', + minChunks: Infinity, + }), ], resolve: { extensions: ['.js', '.es6', '.js.es6'], alias: { '~': path.join(ROOT_PATH, 'app/assets/javascripts'), - 'bootstrap/js': 'bootstrap-sass/assets/javascripts/bootstrap', 'emoji-aliases$': path.join(ROOT_PATH, 'fixtures/emojis/aliases.json'), 'icons': path.join(ROOT_PATH, 'app/views/shared/icons'), 'vendor': path.join(ROOT_PATH, 'vendor/assets/javascripts'), diff --git a/spec/javascripts/test_bundle.js b/spec/javascripts/test_bundle.js index ca707d872a4..fae462561e9 100644 --- a/spec/javascripts/test_bundle.js +++ b/spec/javascripts/test_bundle.js @@ -5,23 +5,12 @@ jasmine.getFixtures().fixturesPath = 'base/spec/javascripts/fixtures'; jasmine.getJSONFixtures().fixturesPath = 'base/spec/javascripts/fixtures'; // include common libraries +require('~/commons/index.js'); window.$ = window.jQuery = require('jquery'); window._ = require('underscore'); window.Cookies = require('js-cookie'); window.Vue = require('vue'); window.Vue.use(require('vue-resource')); -require('jquery-ujs'); -require('bootstrap/js/affix'); -require('bootstrap/js/alert'); -require('bootstrap/js/button'); -require('bootstrap/js/collapse'); -require('bootstrap/js/dropdown'); -require('bootstrap/js/modal'); -require('bootstrap/js/scrollspy'); -require('bootstrap/js/tab'); -require('bootstrap/js/transition'); -require('bootstrap/js/tooltip'); -require('bootstrap/js/popover'); // stub expected globals window.gl = window.gl || {}; -- cgit v1.2.1 From 7371f6cd8c168df41e4197f80f53f3c04185dee7 Mon Sep 17 00:00:00 2001 From: Mike Greiling Date: Wed, 1 Mar 2017 17:02:01 -0600 Subject: refactor common bundle to ES module syntax and move global exports to application.js --- app/assets/javascripts/application.js | 32 ++++++++++++++++++++--------- app/assets/javascripts/commons/bootstrap.js | 18 ++++++++-------- app/assets/javascripts/commons/index.js | 4 ++-- app/assets/javascripts/commons/jquery.js | 21 +++++++++---------- config/webpack.config.js | 6 ++++++ 5 files changed, 49 insertions(+), 32 deletions(-) diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 8441a335ac0..798553c16ac 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -6,14 +6,29 @@ /* global AwardsHandler */ /* global Aside */ -// common libraries -window.Cookies = require('js-cookie'); -window.Pikaday = require('pikaday'); -window._ = require('underscore'); -window.Dropzone = require('dropzone'); -window.Sortable = require('vendor/Sortable'); +import jQuery from 'jquery'; +import _ from 'underscore'; +import Cookies from 'js-cookie'; +import Pikaday from 'pikaday'; +import Dropzone from 'dropzone'; +import Sortable from 'vendor/Sortable'; + +// libraries with import side-effects require('mousetrap'); require('mousetrap/plugins/pause/mousetrap-pause'); +require('vendor/fuzzaldrin-plus'); +require('es6-promise').polyfill(); + +// expose common libraries as globals (TODO: remove these) +window.jQuery = jQuery; +window.$ = jQuery; +window._ = _; +window.Cookies = Cookies; +window.Pikaday = Pikaday; +window.Dropzone = Dropzone; +window.Sortable = Sortable; + +// shortcuts require('./shortcuts'); require('./shortcuts_navigation'); require('./shortcuts_dashboard_navigation'); @@ -189,9 +204,6 @@ require('./visibility_select'); require('./wikis'); require('./zen_mode'); -require('vendor/fuzzaldrin-plus'); -require('es6-promise').polyfill(); - (function () { document.addEventListener('beforeunload', function () { // Unbind scroll events @@ -269,7 +281,7 @@ require('es6-promise').polyfill(); $.fn.tooltip.Constructor.DEFAULTS.trigger = 'hover'; $body.tooltip({ selector: '.has-tooltip, [data-toggle="tooltip"]', - placement: function (_, el) { + placement: function (tip, el) { return $(el).data('placement') || 'bottom'; } }); diff --git a/app/assets/javascripts/commons/bootstrap.js b/app/assets/javascripts/commons/bootstrap.js index 72e43d34a74..db0cbfd87c3 100644 --- a/app/assets/javascripts/commons/bootstrap.js +++ b/app/assets/javascripts/commons/bootstrap.js @@ -1,10 +1,10 @@ -require('./jquery'); +import 'jquery'; -// twitter bootstrap plugins -require('bootstrap-sass/assets/javascripts/bootstrap/affix'); -require('bootstrap-sass/assets/javascripts/bootstrap/alert'); -require('bootstrap-sass/assets/javascripts/bootstrap/dropdown'); -require('bootstrap-sass/assets/javascripts/bootstrap/modal'); -require('bootstrap-sass/assets/javascripts/bootstrap/tab'); -require('bootstrap-sass/assets/javascripts/bootstrap/transition'); -require('bootstrap-sass/assets/javascripts/bootstrap/tooltip'); +// bootstrap jQuery plugins +import 'bootstrap-sass/assets/javascripts/bootstrap/affix'; +import 'bootstrap-sass/assets/javascripts/bootstrap/alert'; +import 'bootstrap-sass/assets/javascripts/bootstrap/dropdown'; +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'; diff --git a/app/assets/javascripts/commons/index.js b/app/assets/javascripts/commons/index.js index a9226bc1325..72ede1d621a 100644 --- a/app/assets/javascripts/commons/index.js +++ b/app/assets/javascripts/commons/index.js @@ -1,2 +1,2 @@ -require('./jquery'); -require('./bootstrap'); +import './jquery'; +import './bootstrap'; diff --git a/app/assets/javascripts/commons/jquery.js b/app/assets/javascripts/commons/jquery.js index 9ef415d6a95..b53f6284afc 100644 --- a/app/assets/javascripts/commons/jquery.js +++ b/app/assets/javascripts/commons/jquery.js @@ -1,12 +1,11 @@ -// jQuery library -window.$ = window.jQuery = require('jquery'); +import 'jquery'; -// jQuery plugins -require('jquery-ujs'); -require('vendor/jquery.endless-scroll'); -require('vendor/jquery.caret'); -require('vendor/jquery.atwho'); -require('vendor/jquery.scrollTo'); -require('vendor/jquery.nicescroll'); -require('vendor/jquery.waitforimages'); -require('select2/select2.js'); +// common jQuery plugins +import 'jquery-ujs'; +import 'vendor/jquery.endless-scroll'; +import 'vendor/jquery.caret'; +import 'vendor/jquery.atwho'; +import 'vendor/jquery.scrollTo'; +import 'vendor/jquery.nicescroll'; +import 'vendor/jquery.waitforimages'; +import 'select2/select2'; diff --git a/config/webpack.config.js b/config/webpack.config.js index c2e0e86fb78..a76daa2ba95 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -87,6 +87,12 @@ var config = { // prevent pikaday from including moment.js new webpack.IgnorePlugin(/moment/, /pikaday/), + // fix legacy jQuery plugins which depend on globals + new webpack.ProvidePlugin({ + $: 'jquery', + jQuery: 'jquery', + }), + // use deterministic module ids in all environments IS_PRODUCTION ? new webpack.HashedModuleIdsPlugin() : -- cgit v1.2.1 From ba865db378b12265442c008a6fc167f2c3c910fb Mon Sep 17 00:00:00 2001 From: Mike Greiling Date: Thu, 2 Mar 2017 00:35:44 -0600 Subject: separate webpack runtime into its own chunk to maintain cacheability of common chunk --- app/views/layouts/_head.html.haml | 1 + config/webpack.config.js | 5 ++--- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml index 29844b1027b..8457d3fd0e2 100644 --- a/app/views/layouts/_head.html.haml +++ b/app/views/layouts/_head.html.haml @@ -28,6 +28,7 @@ = stylesheet_link_tag "application", media: "all" = stylesheet_link_tag "print", media: "print" + = javascript_include_tag(*webpack_asset_paths("manifest")) = javascript_include_tag(*webpack_asset_paths("common")) = javascript_include_tag(*webpack_asset_paths("application")) diff --git a/config/webpack.config.js b/config/webpack.config.js index a76daa2ba95..f262286f5f8 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -98,10 +98,9 @@ var config = { new webpack.HashedModuleIdsPlugin() : new webpack.NamedModulesPlugin(), - // create a common.js bundle to be loaded on every page + // create cacheable common library bundles new webpack.optimize.CommonsChunkPlugin({ - name: 'common', - minChunks: Infinity, + names: ['application', 'common', 'manifest'], }), ], -- cgit v1.2.1 From 14517f977a55fbe0a2371ebb7d33df7c35415e8d Mon Sep 17 00:00:00 2001 From: Mike Greiling Date: Thu, 2 Mar 2017 00:37:07 -0600 Subject: don't rely on global spaced Vue library for issuable bundle --- .../javascripts/issuable/time_tracking/time_tracking_bundle.js.es6 | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/assets/javascripts/issuable/time_tracking/time_tracking_bundle.js.es6 b/app/assets/javascripts/issuable/time_tracking/time_tracking_bundle.js.es6 index 958a0cc6d50..0134b7cb6f3 100644 --- a/app/assets/javascripts/issuable/time_tracking/time_tracking_bundle.js.es6 +++ b/app/assets/javascripts/issuable/time_tracking/time_tracking_bundle.js.es6 @@ -1,5 +1,7 @@ /* global Vue */ +window.Vue = require('vue'); +window.Vue.use(require('vue-resource')); require('./components/time_tracker'); require('../../smart_interval'); require('../../subbable_resource'); -- cgit v1.2.1 From 7f22c39a25b5424ab1fd7905654667a697c82aeb Mon Sep 17 00:00:00 2001 From: Mike Greiling Date: Wed, 1 Mar 2017 20:28:34 -0600 Subject: create a cacheable commons bundle for our Vue bundles --- app/assets/javascripts/lib/vue_resource.js.es6 | 2 -- app/views/projects/boards/_show.html.haml | 1 + app/views/projects/commit/_pipelines_list.haml | 1 + app/views/projects/cycle_analytics/show.html.haml | 1 + app/views/projects/environments/folder.html.haml | 1 + app/views/projects/environments/index.html.haml | 1 + app/views/projects/issues/show.html.haml | 2 +- app/views/projects/merge_requests/_show.html.haml | 1 + app/views/projects/merge_requests/conflicts.html.haml | 2 +- app/views/projects/pipelines/index.html.haml | 1 + app/views/shared/issuable/_sidebar.html.haml | 1 + config/webpack.config.js | 19 ++++++++++++++++++- 12 files changed, 28 insertions(+), 5 deletions(-) delete mode 100644 app/assets/javascripts/lib/vue_resource.js.es6 diff --git a/app/assets/javascripts/lib/vue_resource.js.es6 b/app/assets/javascripts/lib/vue_resource.js.es6 deleted file mode 100644 index 49babdea2e1..00000000000 --- a/app/assets/javascripts/lib/vue_resource.js.es6 +++ /dev/null @@ -1,2 +0,0 @@ -window.Vue = require('vue'); -window.Vue.use(require('vue-resource')); diff --git a/app/views/projects/boards/_show.html.haml b/app/views/projects/boards/_show.html.haml index b3bc6010efb..3ae78387938 100644 --- a/app/views/projects/boards/_show.html.haml +++ b/app/views/projects/boards/_show.html.haml @@ -3,6 +3,7 @@ - page_title "Boards" - content_for :page_specific_javascripts do + = page_specific_javascript_bundle_tag('common_vue') = page_specific_javascript_bundle_tag('boards') = page_specific_javascript_bundle_tag('simulate_drag') if Rails.env.test? diff --git a/app/views/projects/commit/_pipelines_list.haml b/app/views/projects/commit/_pipelines_list.haml index 6792b3f7a83..da5a676274f 100644 --- a/app/views/projects/commit/_pipelines_list.haml +++ b/app/views/projects/commit/_pipelines_list.haml @@ -4,4 +4,5 @@ } } - content_for :page_specific_javascripts do + = page_specific_javascript_bundle_tag('common_vue') = page_specific_javascript_bundle_tag('commit_pipelines') diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml index be17f2c764e..dd3fa814716 100644 --- a/app/views/projects/cycle_analytics/show.html.haml +++ b/app/views/projects/cycle_analytics/show.html.haml @@ -1,6 +1,7 @@ - @no_container = true - page_title "Cycle Analytics" - content_for :page_specific_javascripts do + = page_specific_javascript_bundle_tag('common_vue') = page_specific_javascript_bundle_tag('cycle_analytics') = render "projects/head" diff --git a/app/views/projects/environments/folder.html.haml b/app/views/projects/environments/folder.html.haml index d9cb7bc0331..4b101447bc0 100644 --- a/app/views/projects/environments/folder.html.haml +++ b/app/views/projects/environments/folder.html.haml @@ -3,6 +3,7 @@ = render "projects/pipelines/head" - content_for :page_specific_javascripts do + = page_specific_javascript_bundle_tag('common_vue') = page_specific_javascript_bundle_tag("environments_folder") #environments-folder-list-view{ data: { "can-create-deployment" => can?(current_user, :create_deployment, @project).to_s, diff --git a/app/views/projects/environments/index.html.haml b/app/views/projects/environments/index.html.haml index d6366b57957..80d2b6f5d95 100644 --- a/app/views/projects/environments/index.html.haml +++ b/app/views/projects/environments/index.html.haml @@ -3,6 +3,7 @@ = render "projects/pipelines/head" - content_for :page_specific_javascripts do + = page_specific_javascript_bundle_tag('common_vue') = page_specific_javascript_bundle_tag("environments") #environments-list-view{ data: { environments_data: environments_list_data, diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index 069f3d97943..394f1999f2c 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -3,7 +3,7 @@ - page_description @issue.description - page_card_attributes @issue.card_attributes - content_for :page_specific_javascripts do - = page_specific_javascript_bundle_tag('lib_vue') + = page_specific_javascript_bundle_tag('common_vue') .clearfix.detail-page-header .issuable-header diff --git a/app/views/projects/merge_requests/_show.html.haml b/app/views/projects/merge_requests/_show.html.haml index 521b0694ca9..03d618327d4 100644 --- a/app/views/projects/merge_requests/_show.html.haml +++ b/app/views/projects/merge_requests/_show.html.haml @@ -3,6 +3,7 @@ - page_description @merge_request.description - page_card_attributes @merge_request.card_attributes - content_for :page_specific_javascripts do + = page_specific_javascript_bundle_tag('common_vue') = page_specific_javascript_bundle_tag('diff_notes') .merge-request{ 'data-url' => merge_request_path(@merge_request), 'data-project-path' => project_path(@merge_request.project) } diff --git a/app/views/projects/merge_requests/conflicts.html.haml b/app/views/projects/merge_requests/conflicts.html.haml index 1ecd9924d88..51d59280be8 100644 --- a/app/views/projects/merge_requests/conflicts.html.haml +++ b/app/views/projects/merge_requests/conflicts.html.haml @@ -1,6 +1,6 @@ - page_title "Merge Conflicts", "#{@merge_request.title} (#{@merge_request.to_reference}", "Merge Requests" - content_for :page_specific_javascripts do - = page_specific_javascript_bundle_tag('lib_vue') + = page_specific_javascript_bundle_tag('common_vue') = page_specific_javascript_bundle_tag('merge_conflicts') = page_specific_javascript_tag('lib/ace.js') = render "projects/merge_requests/show/mr_title" diff --git a/app/views/projects/pipelines/index.html.haml b/app/views/projects/pipelines/index.html.haml index acb61aa2490..5d59ce06612 100644 --- a/app/views/projects/pipelines/index.html.haml +++ b/app/views/projects/pipelines/index.html.haml @@ -50,4 +50,5 @@ .content-list.pipelines{ data: { url: namespace_project_pipelines_path(@project.namespace, @project, format: :json) } } .vue-pipelines-index += page_specific_javascript_bundle_tag('common_vue') = page_specific_javascript_bundle_tag('vue_pipelines') diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 37a0c63e514..048fc488207 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -1,5 +1,6 @@ - todo = issuable_todo(issuable) - content_for :page_specific_javascripts do + = page_specific_javascript_bundle_tag('common_vue') = page_specific_javascript_bundle_tag('issuable') %aside.right-sidebar.js-right-sidebar{ data: { "offset-top" => "101", "spy" => "affix" }, class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite' } diff --git a/config/webpack.config.js b/config/webpack.config.js index f262286f5f8..cdae80550dc 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -18,6 +18,7 @@ var config = { context: path.join(ROOT_PATH, 'app/assets/javascripts'), entry: { common: './commons/index.js', + common_vue: ['vue', 'vue-resource'], application: './application.js', blob_edit: './blob_edit/blob_edit_bundle.js', boards: './boards/boards_bundle.js', @@ -41,7 +42,6 @@ var config = { users: './users/users_bundle.js', lib_chart: './lib/chart.js', lib_d3: './lib/d3.js', - lib_vue: './lib/vue_resource.js', vue_pipelines: './vue_pipelines_index/index.js', }, @@ -98,6 +98,23 @@ var config = { new webpack.HashedModuleIdsPlugin() : new webpack.NamedModulesPlugin(), + // create cacheable common library bundle for all vue chunks + new webpack.optimize.CommonsChunkPlugin({ + name: 'common_vue', + chunks: [ + 'boards', + 'commit_pipelines', + 'cycle_analytics', + 'diff_notes', + 'environments', + 'environments_folder', + 'issuable', + 'merge_conflicts', + 'vue_pipelines', + ], + minChunks: Infinity, + }), + // create cacheable common library bundles new webpack.optimize.CommonsChunkPlugin({ names: ['application', 'common', 'manifest'], -- cgit v1.2.1 From 5f464f98f1ff86caac02eca207704c1961b07be1 Mon Sep 17 00:00:00 2001 From: Mike Greiling Date: Thu, 2 Mar 2017 00:58:47 -0600 Subject: include vue_shared scripts within common_vue chunk --- config/webpack.config.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/config/webpack.config.js b/config/webpack.config.js index cdae80550dc..904ac36df4c 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -112,7 +112,9 @@ var config = { 'merge_conflicts', 'vue_pipelines', ], - minChunks: Infinity, + minChunks: function(module, count) { + return module.resource && (/vue_shared/).test(module.resource); + }, }), // create cacheable common library bundles -- cgit v1.2.1 From 1f9743310054273b98e423b6dc5e99a5bc9fd7a2 Mon Sep 17 00:00:00 2001 From: Mike Greiling Date: Thu, 2 Mar 2017 01:27:52 -0600 Subject: merge lib_chart into graphs bundle --- app/assets/javascripts/graphs/graphs_bundle.js | 2 ++ app/assets/javascripts/lib/chart.js | 3 --- app/views/projects/graphs/charts.html.haml | 1 - app/views/projects/graphs/show.html.haml | 1 - app/views/projects/pipelines/charts.html.haml | 1 - config/webpack.config.js | 1 - 6 files changed, 2 insertions(+), 7 deletions(-) delete mode 100644 app/assets/javascripts/lib/chart.js diff --git a/app/assets/javascripts/graphs/graphs_bundle.js b/app/assets/javascripts/graphs/graphs_bundle.js index ea5afbd9d29..a433c7ba8f0 100644 --- a/app/assets/javascripts/graphs/graphs_bundle.js +++ b/app/assets/javascripts/graphs/graphs_bundle.js @@ -1,4 +1,6 @@ +import Chart from 'vendor/Chart'; import ContributorsStatGraph from './stat_graph_contributors'; // export to global scope +window.Chart = Chart; window.ContributorsStatGraph = ContributorsStatGraph; diff --git a/app/assets/javascripts/lib/chart.js b/app/assets/javascripts/lib/chart.js deleted file mode 100644 index 9b011d89e93..00000000000 --- a/app/assets/javascripts/lib/chart.js +++ /dev/null @@ -1,3 +0,0 @@ -/* eslint-disable func-names, space-before-function-paren */ - -window.Chart = require('vendor/Chart'); diff --git a/app/views/projects/graphs/charts.html.haml b/app/views/projects/graphs/charts.html.haml index d3bce45e974..fa28db6c71f 100644 --- a/app/views/projects/graphs/charts.html.haml +++ b/app/views/projects/graphs/charts.html.haml @@ -1,7 +1,6 @@ - @no_container = true - page_title "Charts" - content_for :page_specific_javascripts do - = page_specific_javascript_bundle_tag('lib_chart') = page_specific_javascript_bundle_tag('graphs') = render "projects/commits/head" diff --git a/app/views/projects/graphs/show.html.haml b/app/views/projects/graphs/show.html.haml index d89dfe31e47..05291c65b50 100644 --- a/app/views/projects/graphs/show.html.haml +++ b/app/views/projects/graphs/show.html.haml @@ -1,7 +1,6 @@ - @no_container = true - page_title "Contributors" - content_for :page_specific_javascripts do - = page_specific_javascript_bundle_tag('lib_chart') = page_specific_javascript_bundle_tag('graphs') = render 'projects/commits/head' diff --git a/app/views/projects/pipelines/charts.html.haml b/app/views/projects/pipelines/charts.html.haml index 8ffdfa1a2cf..fbf783afe44 100644 --- a/app/views/projects/pipelines/charts.html.haml +++ b/app/views/projects/pipelines/charts.html.haml @@ -1,7 +1,6 @@ - @no_container = true - page_title "Charts", "Pipelines" - content_for :page_specific_javascripts do - = page_specific_javascript_bundle_tag('lib_chart') = page_specific_javascript_bundle_tag('graphs') = render 'head' diff --git a/config/webpack.config.js b/config/webpack.config.js index 904ac36df4c..243007be4b7 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -40,7 +40,6 @@ var config = { snippet: './snippet/snippet_bundle.js', terminal: './terminal/terminal_bundle.js', users: './users/users_bundle.js', - lib_chart: './lib/chart.js', lib_d3: './lib/d3.js', vue_pipelines: './vue_pipelines_index/index.js', }, -- cgit v1.2.1 From ec9180719ef3d8a3d2aa6bc67b2ec31f91c57613 Mon Sep 17 00:00:00 2001 From: Mike Greiling Date: Thu, 2 Mar 2017 01:34:36 -0600 Subject: create a cacheable commons bundle for d3 library --- app/assets/javascripts/lib/d3.js | 3 --- app/assets/javascripts/users/calendar.js | 3 ++- app/views/projects/graphs/charts.html.haml | 1 + app/views/projects/graphs/show.html.haml | 1 + app/views/projects/pipelines/charts.html.haml | 1 + app/views/users/show.html.haml | 2 +- config/webpack.config.js | 8 +++++++- 7 files changed, 13 insertions(+), 6 deletions(-) delete mode 100644 app/assets/javascripts/lib/d3.js diff --git a/app/assets/javascripts/lib/d3.js b/app/assets/javascripts/lib/d3.js deleted file mode 100644 index a9dd32edbed..00000000000 --- a/app/assets/javascripts/lib/d3.js +++ /dev/null @@ -1,3 +0,0 @@ -/* eslint-disable func-names, space-before-function-paren */ - -window.d3 = require('d3'); diff --git a/app/assets/javascripts/users/calendar.js b/app/assets/javascripts/users/calendar.js index 5111b260e1c..754d448564f 100644 --- a/app/assets/javascripts/users/calendar.js +++ b/app/assets/javascripts/users/calendar.js @@ -1,5 +1,6 @@ /* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, camelcase, vars-on-top, object-shorthand, comma-dangle, eqeqeq, no-mixed-operators, no-return-assign, newline-per-chained-call, prefer-arrow-callback, consistent-return, one-var, one-var-declaration-per-line, prefer-template, quotes, no-unused-vars, no-else-return, max-len */ -/* global d3 */ + +import d3 from 'd3'; (function() { var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }; diff --git a/app/views/projects/graphs/charts.html.haml b/app/views/projects/graphs/charts.html.haml index fa28db6c71f..464ac34d961 100644 --- a/app/views/projects/graphs/charts.html.haml +++ b/app/views/projects/graphs/charts.html.haml @@ -1,6 +1,7 @@ - @no_container = true - page_title "Charts" - content_for :page_specific_javascripts do + = page_specific_javascript_bundle_tag('common_d3') = page_specific_javascript_bundle_tag('graphs') = render "projects/commits/head" diff --git a/app/views/projects/graphs/show.html.haml b/app/views/projects/graphs/show.html.haml index 05291c65b50..680f8ae6c8f 100644 --- a/app/views/projects/graphs/show.html.haml +++ b/app/views/projects/graphs/show.html.haml @@ -1,6 +1,7 @@ - @no_container = true - page_title "Contributors" - content_for :page_specific_javascripts do + = page_specific_javascript_bundle_tag('common_d3') = page_specific_javascript_bundle_tag('graphs') = render 'projects/commits/head' diff --git a/app/views/projects/pipelines/charts.html.haml b/app/views/projects/pipelines/charts.html.haml index fbf783afe44..4a5043aac3c 100644 --- a/app/views/projects/pipelines/charts.html.haml +++ b/app/views/projects/pipelines/charts.html.haml @@ -1,6 +1,7 @@ - @no_container = true - page_title "Charts", "Pipelines" - content_for :page_specific_javascripts do + = page_specific_javascript_bundle_tag('common_d3') = page_specific_javascript_bundle_tag('graphs') = render 'head' diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml index af091f9ab88..76cd330e80a 100644 --- a/app/views/users/show.html.haml +++ b/app/views/users/show.html.haml @@ -1,7 +1,7 @@ - page_title @user.name - page_description @user.bio - content_for :page_specific_javascripts do - = page_specific_javascript_bundle_tag('lib_d3') + = page_specific_javascript_bundle_tag('common_d3') = page_specific_javascript_bundle_tag('users') - header_title @user.name, user_path(@user) - @no_container = true diff --git a/config/webpack.config.js b/config/webpack.config.js index 243007be4b7..eb453b5997b 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -19,6 +19,7 @@ var config = { entry: { common: './commons/index.js', common_vue: ['vue', 'vue-resource'], + common_d3: ['d3'], application: './application.js', blob_edit: './blob_edit/blob_edit_bundle.js', boards: './boards/boards_bundle.js', @@ -40,7 +41,6 @@ var config = { snippet: './snippet/snippet_bundle.js', terminal: './terminal/terminal_bundle.js', users: './users/users_bundle.js', - lib_d3: './lib/d3.js', vue_pipelines: './vue_pipelines_index/index.js', }, @@ -116,6 +116,12 @@ var config = { }, }), + // create cacheable common library bundle for all d3 chunks + new webpack.optimize.CommonsChunkPlugin({ + name: 'common_d3', + chunks: ['graphs', 'users'], + }), + // create cacheable common library bundles new webpack.optimize.CommonsChunkPlugin({ names: ['application', 'common', 'manifest'], -- cgit v1.2.1 From 59f51cbffd61d798179da60d62406a61f8658957 Mon Sep 17 00:00:00 2001 From: Mike Greiling Date: Thu, 2 Mar 2017 02:11:23 -0600 Subject: add CHANGELOG.md entry for !9647 --- changelogs/unreleased/commons-chunk-plugin.yml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changelogs/unreleased/commons-chunk-plugin.yml diff --git a/changelogs/unreleased/commons-chunk-plugin.yml b/changelogs/unreleased/commons-chunk-plugin.yml new file mode 100644 index 00000000000..5c11ea3bbb2 --- /dev/null +++ b/changelogs/unreleased/commons-chunk-plugin.yml @@ -0,0 +1,5 @@ +--- +title: Use webpack CommonsChunkPlugin to place common javascript libraries in their + own bundles +merge_request: 9647 +author: -- cgit v1.2.1 From f5f0be534aafd4a2bf1acd9b9154537343fff66e Mon Sep 17 00:00:00 2001 From: Mike Greiling Date: Thu, 2 Mar 2017 16:35:36 -0600 Subject: remove problematic plugins from karma's webpack config --- config/karma.config.js | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/config/karma.config.js b/config/karma.config.js index 2f3cc932413..a23e62f5022 100644 --- a/config/karma.config.js +++ b/config/karma.config.js @@ -1,9 +1,10 @@ var path = require('path'); +var webpack = require('webpack'); var webpackConfig = require('./webpack.config.js'); var ROOT_PATH = path.resolve(__dirname, '..'); // add coverage instrumentation to babel config -if (webpackConfig && webpackConfig.module && webpackConfig.module.rules) { +if (webpackConfig.module && webpackConfig.module.rules) { var babelConfig = webpackConfig.module.rules.find(function (rule) { return rule.loader === 'babel-loader'; }); @@ -13,6 +14,16 @@ if (webpackConfig && webpackConfig.module && webpackConfig.module.rules) { babelConfig.options.plugins.push('istanbul'); } +// remove problematic plugins +if (webpackConfig.plugins) { + webpackConfig.plugins = webpackConfig.plugins.filter(function (plugin) { + return !( + plugin instanceof webpack.optimize.CommonsChunkPlugin || + plugin instanceof webpack.DefinePlugin + ); + }); +} + // Karma configuration module.exports = function(config) { var progressReporter = process.env.CI ? 'mocha' : 'progress'; -- cgit v1.2.1 From d0d34786e9bdda51dfecc59a419082db8c523ddc Mon Sep 17 00:00:00 2001 From: Mike Greiling Date: Thu, 2 Mar 2017 17:00:48 -0600 Subject: rename application entry point and change manifest to runtime --- app/assets/javascripts/application.js | 392 ---------------------------------- app/assets/javascripts/main.js | 392 ++++++++++++++++++++++++++++++++++ app/views/layouts/_head.html.haml | 4 +- config/webpack.config.js | 6 +- 4 files changed, 397 insertions(+), 397 deletions(-) delete mode 100644 app/assets/javascripts/application.js create mode 100644 app/assets/javascripts/main.js diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js deleted file mode 100644 index 798553c16ac..00000000000 --- a/app/assets/javascripts/application.js +++ /dev/null @@ -1,392 +0,0 @@ -/* eslint-disable func-names, space-before-function-paren, no-var, quotes, consistent-return, prefer-arrow-callback, comma-dangle, object-shorthand, no-new, max-len, no-multi-spaces, import/newline-after-import */ -/* global bp */ -/* global Cookies */ -/* global Flash */ -/* global ConfirmDangerModal */ -/* global AwardsHandler */ -/* global Aside */ - -import jQuery from 'jquery'; -import _ from 'underscore'; -import Cookies from 'js-cookie'; -import Pikaday from 'pikaday'; -import Dropzone from 'dropzone'; -import Sortable from 'vendor/Sortable'; - -// libraries with import side-effects -require('mousetrap'); -require('mousetrap/plugins/pause/mousetrap-pause'); -require('vendor/fuzzaldrin-plus'); -require('es6-promise').polyfill(); - -// expose common libraries as globals (TODO: remove these) -window.jQuery = jQuery; -window.$ = jQuery; -window._ = _; -window.Cookies = Cookies; -window.Pikaday = Pikaday; -window.Dropzone = Dropzone; -window.Sortable = Sortable; - -// shortcuts -require('./shortcuts'); -require('./shortcuts_navigation'); -require('./shortcuts_dashboard_navigation'); -require('./shortcuts_issuable'); -require('./shortcuts_network'); - -// behaviors -require('./behaviors/autosize'); -require('./behaviors/details_behavior'); -require('./behaviors/quick_submit'); -require('./behaviors/requires_input'); -require('./behaviors/toggler_behavior'); - -// blob -require('./blob/blob_ci_yaml'); -require('./blob/blob_dockerfile_selector'); -require('./blob/blob_dockerfile_selectors'); -require('./blob/blob_file_dropzone'); -require('./blob/blob_gitignore_selector'); -require('./blob/blob_gitignore_selectors'); -require('./blob/blob_license_selector'); -require('./blob/blob_license_selectors'); -require('./blob/template_selector'); - -// templates -require('./templates/issuable_template_selector'); -require('./templates/issuable_template_selectors'); - -// commit -require('./commit/file.js'); -require('./commit/image_file.js'); - -// extensions -require('./extensions/array'); -require('./extensions/custom_event'); -require('./extensions/element'); -require('./extensions/jquery'); -require('./extensions/object'); - -// lib/utils -require('./lib/utils/animate'); -require('./lib/utils/bootstrap_linked_tabs'); -require('./lib/utils/common_utils'); -require('./lib/utils/datetime_utility'); -require('./lib/utils/notify'); -require('./lib/utils/pretty_time'); -require('./lib/utils/text_utility'); -require('./lib/utils/type_utility'); -require('./lib/utils/url_utility'); - -// u2f -require('./u2f/authenticate'); -require('./u2f/error'); -require('./u2f/register'); -require('./u2f/util'); - -// droplab -require('./droplab/droplab'); -require('./droplab/droplab_ajax'); -require('./droplab/droplab_ajax_filter'); -require('./droplab/droplab_filter'); - -// everything else -require('./abuse_reports'); -require('./activities'); -require('./admin'); -require('./ajax_loading_spinner'); -require('./api'); -require('./aside'); -require('./autosave'); -require('./awards_handler'); -require('./breakpoints'); -require('./broadcast_message'); -require('./build'); -require('./build_artifacts'); -require('./build_variables'); -require('./ci_lint_editor'); -require('./commit'); -require('./commits'); -require('./compare'); -require('./compare_autocomplete'); -require('./confirm_danger_modal'); -require('./copy_as_gfm'); -require('./copy_to_clipboard'); -require('./create_label'); -require('./diff'); -require('./dispatcher'); -require('./dropzone_input'); -require('./due_date_select'); -require('./files_comment_button'); -require('./flash'); -require('./gfm_auto_complete'); -require('./gl_dropdown'); -require('./gl_field_error'); -require('./gl_field_errors'); -require('./gl_form'); -require('./group_avatar'); -require('./group_label_subscription'); -require('./groups_select'); -require('./header'); -require('./importer_status'); -require('./issuable'); -require('./issuable_context'); -require('./issuable_form'); -require('./issue'); -require('./issue_status_select'); -require('./issues_bulk_assignment'); -require('./label_manager'); -require('./labels'); -require('./labels_select'); -require('./layout_nav'); -require('./line_highlighter'); -require('./logo'); -require('./member_expiration_date'); -require('./members'); -require('./merge_request'); -require('./merge_request_tabs'); -require('./merge_request_widget'); -require('./merged_buttons'); -require('./milestone'); -require('./milestone_select'); -require('./mini_pipeline_graph_dropdown'); -require('./namespace_select'); -require('./new_branch_form'); -require('./new_commit_form'); -require('./notes'); -require('./notifications_dropdown'); -require('./notifications_form'); -require('./pager'); -require('./pipelines'); -require('./preview_markdown'); -require('./project'); -require('./project_avatar'); -require('./project_find_file'); -require('./project_fork'); -require('./project_import'); -require('./project_label_subscription'); -require('./project_new'); -require('./project_select'); -require('./project_show'); -require('./project_variables'); -require('./projects_list'); -require('./render_gfm'); -require('./render_math'); -require('./right_sidebar'); -require('./search'); -require('./search_autocomplete'); -require('./shortcuts'); -require('./shortcuts_blob'); -require('./shortcuts_dashboard_navigation'); -require('./shortcuts_find_file'); -require('./shortcuts_issuable'); -require('./shortcuts_navigation'); -require('./shortcuts_network'); -require('./signin_tabs_memoizer'); -require('./single_file_diff'); -require('./smart_interval'); -require('./snippets_list'); -require('./star'); -require('./subbable_resource'); -require('./subscription'); -require('./subscription_select'); -require('./syntax_highlight'); -require('./task_list'); -require('./todos'); -require('./tree'); -require('./user'); -require('./user_tabs'); -require('./username_validator'); -require('./users_select'); -require('./version_check_image'); -require('./visibility_select'); -require('./wikis'); -require('./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); - - $(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; - - // Set the default path for all cookies to GitLab's root directory - Cookies.defaults.path = gon.relative_url_root || '/'; - - // `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); - } - }); - - // prevent default action for disabled buttons - $('.btn').click(function(e) { - if ($(this).hasClass('disabled')) { - e.preventDefault(); - e.stopImmediatePropagation(); - return false; - } - }); - - $('.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(); - } - // 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').show(); - } else { - notesHolders.hide(); - } - $this.trigger('blur'); - 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'); - } - } - }); - 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(); - }); -}).call(window); diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js new file mode 100644 index 00000000000..798553c16ac --- /dev/null +++ b/app/assets/javascripts/main.js @@ -0,0 +1,392 @@ +/* eslint-disable func-names, space-before-function-paren, no-var, quotes, consistent-return, prefer-arrow-callback, comma-dangle, object-shorthand, no-new, max-len, no-multi-spaces, import/newline-after-import */ +/* global bp */ +/* global Cookies */ +/* global Flash */ +/* global ConfirmDangerModal */ +/* global AwardsHandler */ +/* global Aside */ + +import jQuery from 'jquery'; +import _ from 'underscore'; +import Cookies from 'js-cookie'; +import Pikaday from 'pikaday'; +import Dropzone from 'dropzone'; +import Sortable from 'vendor/Sortable'; + +// libraries with import side-effects +require('mousetrap'); +require('mousetrap/plugins/pause/mousetrap-pause'); +require('vendor/fuzzaldrin-plus'); +require('es6-promise').polyfill(); + +// expose common libraries as globals (TODO: remove these) +window.jQuery = jQuery; +window.$ = jQuery; +window._ = _; +window.Cookies = Cookies; +window.Pikaday = Pikaday; +window.Dropzone = Dropzone; +window.Sortable = Sortable; + +// shortcuts +require('./shortcuts'); +require('./shortcuts_navigation'); +require('./shortcuts_dashboard_navigation'); +require('./shortcuts_issuable'); +require('./shortcuts_network'); + +// behaviors +require('./behaviors/autosize'); +require('./behaviors/details_behavior'); +require('./behaviors/quick_submit'); +require('./behaviors/requires_input'); +require('./behaviors/toggler_behavior'); + +// blob +require('./blob/blob_ci_yaml'); +require('./blob/blob_dockerfile_selector'); +require('./blob/blob_dockerfile_selectors'); +require('./blob/blob_file_dropzone'); +require('./blob/blob_gitignore_selector'); +require('./blob/blob_gitignore_selectors'); +require('./blob/blob_license_selector'); +require('./blob/blob_license_selectors'); +require('./blob/template_selector'); + +// templates +require('./templates/issuable_template_selector'); +require('./templates/issuable_template_selectors'); + +// commit +require('./commit/file.js'); +require('./commit/image_file.js'); + +// extensions +require('./extensions/array'); +require('./extensions/custom_event'); +require('./extensions/element'); +require('./extensions/jquery'); +require('./extensions/object'); + +// lib/utils +require('./lib/utils/animate'); +require('./lib/utils/bootstrap_linked_tabs'); +require('./lib/utils/common_utils'); +require('./lib/utils/datetime_utility'); +require('./lib/utils/notify'); +require('./lib/utils/pretty_time'); +require('./lib/utils/text_utility'); +require('./lib/utils/type_utility'); +require('./lib/utils/url_utility'); + +// u2f +require('./u2f/authenticate'); +require('./u2f/error'); +require('./u2f/register'); +require('./u2f/util'); + +// droplab +require('./droplab/droplab'); +require('./droplab/droplab_ajax'); +require('./droplab/droplab_ajax_filter'); +require('./droplab/droplab_filter'); + +// everything else +require('./abuse_reports'); +require('./activities'); +require('./admin'); +require('./ajax_loading_spinner'); +require('./api'); +require('./aside'); +require('./autosave'); +require('./awards_handler'); +require('./breakpoints'); +require('./broadcast_message'); +require('./build'); +require('./build_artifacts'); +require('./build_variables'); +require('./ci_lint_editor'); +require('./commit'); +require('./commits'); +require('./compare'); +require('./compare_autocomplete'); +require('./confirm_danger_modal'); +require('./copy_as_gfm'); +require('./copy_to_clipboard'); +require('./create_label'); +require('./diff'); +require('./dispatcher'); +require('./dropzone_input'); +require('./due_date_select'); +require('./files_comment_button'); +require('./flash'); +require('./gfm_auto_complete'); +require('./gl_dropdown'); +require('./gl_field_error'); +require('./gl_field_errors'); +require('./gl_form'); +require('./group_avatar'); +require('./group_label_subscription'); +require('./groups_select'); +require('./header'); +require('./importer_status'); +require('./issuable'); +require('./issuable_context'); +require('./issuable_form'); +require('./issue'); +require('./issue_status_select'); +require('./issues_bulk_assignment'); +require('./label_manager'); +require('./labels'); +require('./labels_select'); +require('./layout_nav'); +require('./line_highlighter'); +require('./logo'); +require('./member_expiration_date'); +require('./members'); +require('./merge_request'); +require('./merge_request_tabs'); +require('./merge_request_widget'); +require('./merged_buttons'); +require('./milestone'); +require('./milestone_select'); +require('./mini_pipeline_graph_dropdown'); +require('./namespace_select'); +require('./new_branch_form'); +require('./new_commit_form'); +require('./notes'); +require('./notifications_dropdown'); +require('./notifications_form'); +require('./pager'); +require('./pipelines'); +require('./preview_markdown'); +require('./project'); +require('./project_avatar'); +require('./project_find_file'); +require('./project_fork'); +require('./project_import'); +require('./project_label_subscription'); +require('./project_new'); +require('./project_select'); +require('./project_show'); +require('./project_variables'); +require('./projects_list'); +require('./render_gfm'); +require('./render_math'); +require('./right_sidebar'); +require('./search'); +require('./search_autocomplete'); +require('./shortcuts'); +require('./shortcuts_blob'); +require('./shortcuts_dashboard_navigation'); +require('./shortcuts_find_file'); +require('./shortcuts_issuable'); +require('./shortcuts_navigation'); +require('./shortcuts_network'); +require('./signin_tabs_memoizer'); +require('./single_file_diff'); +require('./smart_interval'); +require('./snippets_list'); +require('./star'); +require('./subbable_resource'); +require('./subscription'); +require('./subscription_select'); +require('./syntax_highlight'); +require('./task_list'); +require('./todos'); +require('./tree'); +require('./user'); +require('./user_tabs'); +require('./username_validator'); +require('./users_select'); +require('./version_check_image'); +require('./visibility_select'); +require('./wikis'); +require('./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); + + $(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; + + // Set the default path for all cookies to GitLab's root directory + Cookies.defaults.path = gon.relative_url_root || '/'; + + // `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); + } + }); + + // prevent default action for disabled buttons + $('.btn').click(function(e) { + if ($(this).hasClass('disabled')) { + e.preventDefault(); + e.stopImmediatePropagation(); + return false; + } + }); + + $('.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(); + } + // 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').show(); + } else { + notesHolders.hide(); + } + $this.trigger('blur'); + 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'); + } + } + }); + 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(); + }); +}).call(window); diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml index 8457d3fd0e2..f6d8bb08a64 100644 --- a/app/views/layouts/_head.html.haml +++ b/app/views/layouts/_head.html.haml @@ -28,9 +28,9 @@ = stylesheet_link_tag "application", media: "all" = stylesheet_link_tag "print", media: "print" - = javascript_include_tag(*webpack_asset_paths("manifest")) + = javascript_include_tag(*webpack_asset_paths("runtime")) = javascript_include_tag(*webpack_asset_paths("common")) - = javascript_include_tag(*webpack_asset_paths("application")) + = javascript_include_tag(*webpack_asset_paths("main")) - if content_for?(:page_specific_javascripts) = yield :page_specific_javascripts diff --git a/config/webpack.config.js b/config/webpack.config.js index eb453b5997b..d9fa70c29fb 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -20,7 +20,7 @@ var config = { common: './commons/index.js', common_vue: ['vue', 'vue-resource'], common_d3: ['d3'], - application: './application.js', + main: './main.js', blob_edit: './blob_edit/blob_edit_bundle.js', boards: './boards/boards_bundle.js', simulate_drag: './test_utils/simulate_drag.js', @@ -47,7 +47,7 @@ var config = { output: { path: path.join(ROOT_PATH, 'public/assets/webpack'), publicPath: '/assets/webpack/', - filename: IS_PRODUCTION ? '[name]-[chunkhash].js' : '[name].js' + filename: IS_PRODUCTION ? '[name].[chunkhash].bundle.js' : '[name].bundle.js' }, devtool: 'inline-source-map', @@ -124,7 +124,7 @@ var config = { // create cacheable common library bundles new webpack.optimize.CommonsChunkPlugin({ - names: ['application', 'common', 'manifest'], + names: ['main', 'common', 'runtime'], }), ], -- cgit v1.2.1 From ed3e3317de6e386364c1949b939c087b7aed6258 Mon Sep 17 00:00:00 2001 From: Mike Greiling Date: Thu, 2 Mar 2017 17:05:06 -0600 Subject: remove isolated common_vue inclusion --- app/views/projects/issues/show.html.haml | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index 394f1999f2c..d39f36e94c7 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -2,8 +2,6 @@ - page_title "#{@issue.title} (#{@issue.to_reference})", "Issues" - page_description @issue.description - page_card_attributes @issue.card_attributes -- content_for :page_specific_javascripts do - = page_specific_javascript_bundle_tag('common_vue') .clearfix.detail-page-header .issuable-header -- cgit v1.2.1 From 982dd5040b3bb2b74534ecddf6fc6c3668ae08c0 Mon Sep 17 00:00:00 2001 From: Mike Greiling Date: Fri, 3 Mar 2017 16:54:20 -0600 Subject: merge cropper library into profile_bundle --- app/assets/javascripts/lib/cropper.js | 7 ------- app/assets/javascripts/profile/gl_crop.js.es6 | 2 ++ app/views/profiles/_head.html.haml | 1 - config/application.rb | 1 - 4 files changed, 2 insertions(+), 9 deletions(-) delete mode 100644 app/assets/javascripts/lib/cropper.js diff --git a/app/assets/javascripts/lib/cropper.js b/app/assets/javascripts/lib/cropper.js deleted file mode 100644 index 7862c6797c3..00000000000 --- a/app/assets/javascripts/lib/cropper.js +++ /dev/null @@ -1,7 +0,0 @@ -/* eslint-disable func-names, space-before-function-paren */ - -/*= require cropper */ - -(function() { - -}).call(window); diff --git a/app/assets/javascripts/profile/gl_crop.js.es6 b/app/assets/javascripts/profile/gl_crop.js.es6 index 192b1192d07..cf1566eeb87 100644 --- a/app/assets/javascripts/profile/gl_crop.js.es6 +++ b/app/assets/javascripts/profile/gl_crop.js.es6 @@ -1,5 +1,7 @@ /* eslint-disable no-useless-escape, max-len, quotes, no-var, no-underscore-dangle, func-names, space-before-function-paren, no-unused-vars, no-return-assign, object-shorthand, one-var, one-var-declaration-per-line, comma-dangle, consistent-return, class-methods-use-this, new-parens */ +import 'vendor/cropper'; + ((global) => { // Matches everything but the file name const FILENAMEREGEX = /^.*[\\\/]/; diff --git a/app/views/profiles/_head.html.haml b/app/views/profiles/_head.html.haml index 1df04ea614e..83ae9129807 100644 --- a/app/views/profiles/_head.html.haml +++ b/app/views/profiles/_head.html.haml @@ -1,3 +1,2 @@ - content_for :page_specific_javascripts do - = page_specific_javascript_tag('lib/cropper.js') = page_specific_javascript_bundle_tag('profile') diff --git a/config/application.rb b/config/application.rb index 45f3b20d214..f1a986d1731 100644 --- a/config/application.rb +++ b/config/application.rb @@ -100,7 +100,6 @@ module Gitlab config.assets.precompile << "katex.js" config.assets.precompile << "xterm/xterm.css" config.assets.precompile << "lib/ace.js" - config.assets.precompile << "lib/cropper.js" config.assets.precompile << "lib/raphael.js" config.assets.precompile << "u2f.js" config.assets.precompile << "vendor/assets/fonts/*" -- cgit v1.2.1 From ffd970d97dc9faf82752e6494aab8ba6fdce759a Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Fri, 3 Mar 2017 14:12:35 -0800 Subject: Make SidekiqStatus able to count number of jobs completed/running --- lib/gitlab/sidekiq_status.rb | 35 ++++++++++++++++++++++++++++------ spec/lib/gitlab/sidekiq_status_spec.rb | 26 +++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/lib/gitlab/sidekiq_status.rb b/lib/gitlab/sidekiq_status.rb index aadc401ff8d..11e5f1b645c 100644 --- a/lib/gitlab/sidekiq_status.rb +++ b/lib/gitlab/sidekiq_status.rb @@ -44,19 +44,42 @@ module Gitlab # Returns true if all the given job have been completed. # - # jids - The Sidekiq job IDs to check. + # job_ids - The Sidekiq job IDs to check. # # Returns true or false. - def self.all_completed?(jids) - keys = jids.map { |jid| key_for(jid) } + def self.all_completed?(job_ids) + self.num_running(job_ids).zero? + end + + # Returns the number of jobs that are running. + # + # job_ids - The Sidekiq job IDs to check. + def self.num_running(job_ids) + responses = self.job_status(job_ids) - responses = Sidekiq.redis do |redis| + responses.select(&:present?).count + end + + # Returns the number of jobs that have completed. + # + # job_ids - The Sidekiq job IDs to check. + def self.num_completed(job_ids) + job_ids.size - self.num_running(job_ids) + end + + # Returns the job status for each of the given job IDs. + # + # job_ids - The Sidekiq job IDs to check. + # + # Returns an array of true or false indicating job completion. + def self.job_status(job_ids) + keys = job_ids.map { |jid| key_for(jid) } + + Sidekiq.redis do |redis| redis.pipelined do keys.each { |key| redis.exists(key) } end end - - responses.all? { |value| !value } end def self.key_for(jid) diff --git a/spec/lib/gitlab/sidekiq_status_spec.rb b/spec/lib/gitlab/sidekiq_status_spec.rb index 0aa36a3416b..56f06b61afb 100644 --- a/spec/lib/gitlab/sidekiq_status_spec.rb +++ b/spec/lib/gitlab/sidekiq_status_spec.rb @@ -39,6 +39,32 @@ describe Gitlab::SidekiqStatus do end end + describe '.num_running', :redis do + it 'returns 0 if all jobs have been completed' do + expect(described_class.num_running(%w(123))).to eq(0) + end + + it 'returns 2 if two jobs are still running' do + described_class.set('123') + described_class.set('456') + + expect(described_class.num_running(%w(123 456 789))).to eq(2) + end + end + + describe '.num_completed', :redis do + it 'returns 1 if all jobs have been completed' do + expect(described_class.num_completed(%w(123))).to eq(1) + end + + it 'returns 1 if a job has not yet been completed' do + described_class.set('123') + described_class.set('456') + + expect(described_class.num_completed(%w(123 456 789))).to eq(1) + end + end + describe '.key_for' do it 'returns the key for a job ID' do key = described_class.key_for('123') -- cgit v1.2.1 From 305f19ca266d3494567a885b67dfc13d602cf860 Mon Sep 17 00:00:00 2001 From: Vignesh Ravichandran Date: Fri, 3 Mar 2017 23:28:34 -0800 Subject: Be able to list issues with no labels using API --- doc/api/issues.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/doc/api/issues.md b/doc/api/issues.md index 51ce08c873e..0f22d0b7f94 100644 --- a/doc/api/issues.md +++ b/doc/api/issues.md @@ -23,6 +23,7 @@ GET /issues?state=closed GET /issues?labels=foo GET /issues?labels=foo,bar GET /issues?labels=foo,bar&state=opened +GET /projects/:id/issues?labels_name=No+Label GET /issues?milestone=1.0.0 GET /issues?milestone=1.0.0&state=opened GET /issues?iids[]=42&iids[]=43 @@ -32,6 +33,7 @@ GET /issues?iids[]=42&iids[]=43 | --------- | ---- | -------- | ----------- | | `state` | string | no | Return all issues or just those that are `opened` or `closed`| | `labels` | string | no | Comma-separated list of label names, issues must have all labels to be returned | +| `labels_name` | string | no | Return all issues with the mentioned label. `No+Label` lists all issues with no labels | | `milestone` | string| no | The milestone title | | `iids` | Array[integer] | no | Return only the issues having the given `iid` | | `order_by`| string | no | Return requests ordered by `created_at` or `updated_at` fields. Default is `created_at` | @@ -102,6 +104,7 @@ GET /groups/:id/issues?state=closed GET /groups/:id/issues?labels=foo GET /groups/:id/issues?labels=foo,bar GET /groups/:id/issues?labels=foo,bar&state=opened +GET /projects/:id/issues?labels_name=No+Label GET /groups/:id/issues?milestone=1.0.0 GET /groups/:id/issues?milestone=1.0.0&state=opened GET /groups/:id/issues?iids[]=42&iids[]=43 @@ -112,6 +115,7 @@ GET /groups/:id/issues?iids[]=42&iids[]=43 | `id` | integer | yes | The ID of a group | | `state` | string | no | Return all issues or just those that are `opened` or `closed`| | `labels` | string | no | Comma-separated list of label names, issues must have all labels to be returned | +| `labels_name` | string | no | Return all issues with the mentioned label. `No+Label` lists all issues with no labels | | `iids` | Array[integer] | no | Return only the issues having the given `iid` | | `milestone` | string| no | The milestone title | | `order_by`| string | no | Return requests ordered by `created_at` or `updated_at` fields. Default is `created_at` | @@ -183,6 +187,7 @@ GET /projects/:id/issues?state=closed GET /projects/:id/issues?labels=foo GET /projects/:id/issues?labels=foo,bar GET /projects/:id/issues?labels=foo,bar&state=opened +GET /projects/:id/issues?labels_name=No+Label GET /projects/:id/issues?milestone=1.0.0 GET /projects/:id/issues?milestone=1.0.0&state=opened GET /projects/:id/issues?iids[]=42&iids[]=43 @@ -194,6 +199,7 @@ GET /projects/:id/issues?iids[]=42&iids[]=43 | `iids` | Array[integer] | no | Return only the milestone having the given `iid` | | `state` | string | no | Return all issues or just those that are `opened` or `closed`| | `labels` | string | no | Comma-separated list of label names, issues must have all labels to be returned | +| `labels_name` | string | no | Return all issues with the mentioned label. `No+Label` lists all issues with no labels | | `milestone` | string| no | The milestone title | | `order_by`| string | no | Return requests ordered by `created_at` or `updated_at` fields. Default is `created_at` | | `sort` | string | no | Return requests sorted in `asc` or `desc` order. Default is `desc` | -- cgit v1.2.1 From 9c01ae091d918b77bcd911477ec5b02945add4fd Mon Sep 17 00:00:00 2001 From: Vignesh Ravichandran Date: Sat, 4 Mar 2017 00:25:15 -0800 Subject: Adds changelog entry --- changelogs/unreleased/list_issues_with_no_labels.yml | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelogs/unreleased/list_issues_with_no_labels.yml diff --git a/changelogs/unreleased/list_issues_with_no_labels.yml b/changelogs/unreleased/list_issues_with_no_labels.yml new file mode 100644 index 00000000000..ab44841631b --- /dev/null +++ b/changelogs/unreleased/list_issues_with_no_labels.yml @@ -0,0 +1,4 @@ +--- +title: Document ability to list issues with no labels using API +merge_request: 9697 +author: Vignesh Ravichandran -- cgit v1.2.1 From 8a910ba297b229171a64794b8958401431354b4a Mon Sep 17 00:00:00 2001 From: Dmitriy Zaporozhets Date: Sat, 4 Mar 2017 13:04:00 +0200 Subject: Improve projects/groups list js code Signed-off-by: Dmitriy Zaporozhets --- app/assets/javascripts/filterable_list.js | 2 -- app/assets/javascripts/groups_list.js | 11 +++++++---- app/assets/javascripts/projects_list.js | 11 +++++++---- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/app/assets/javascripts/filterable_list.js b/app/assets/javascripts/filterable_list.js index f498c3ea973..47a40e28461 100644 --- a/app/assets/javascripts/filterable_list.js +++ b/app/assets/javascripts/filterable_list.js @@ -7,8 +7,6 @@ export default class FilterableList { this.filterForm = form; this.listFilterElement = filter; this.listHolderElement = holder; - - this.initSearch(); } initSearch() { diff --git a/app/assets/javascripts/groups_list.js b/app/assets/javascripts/groups_list.js index 49b29affaa5..56a8cbf6d03 100644 --- a/app/assets/javascripts/groups_list.js +++ b/app/assets/javascripts/groups_list.js @@ -6,10 +6,13 @@ import FilterableList from './filterable_list'; */ export default class GroupsList { constructor() { - var form = document.querySelector('form#group-filter-form'); - var filter = document.querySelector('.js-groups-list-filter'); - var holder = document.querySelector('.js-groups-list-holder'); + const form = document.querySelector('form#group-filter-form'); + const filter = document.querySelector('.js-groups-list-filter'); + const holder = document.querySelector('.js-groups-list-holder'); - new FilterableList(form, filter, holder); + if (form && filter && holder) { + const list = new FilterableList(form, filter, holder); + list.initSearch(); + } } } diff --git a/app/assets/javascripts/projects_list.js b/app/assets/javascripts/projects_list.js index 383c2815457..c67d59d2be5 100644 --- a/app/assets/javascripts/projects_list.js +++ b/app/assets/javascripts/projects_list.js @@ -6,10 +6,13 @@ import FilterableList from './filterable_list'; */ export default class ProjectsList { constructor() { - var form = document.querySelector('form#project-filter-form'); - var filter = document.querySelector('.js-projects-list-filter'); - var holder = document.querySelector('.js-projects-list-holder'); + const form = document.querySelector('form#project-filter-form'); + const filter = document.querySelector('.js-projects-list-filter'); + const holder = document.querySelector('.js-projects-list-holder'); - new FilterableList(form, filter, holder); + if (form && filter && holder) { + const list = new FilterableList(form, filter, holder); + list.initSearch(); + } } } -- cgit v1.2.1 From c13c1e1b166edd5520057814dc4698450b578c9a Mon Sep 17 00:00:00 2001 From: Dmitriy Zaporozhets Date: Sat, 4 Mar 2017 15:30:12 +0200 Subject: Improve explore projects spinach test Project name "Internal" is too generic and can lead to false positive/negative when there is a visibility filter on the page. So we ensure we check for project inside list holder css class. Signed-off-by: Dmitriy Zaporozhets --- features/steps/shared/project.rb | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/features/steps/shared/project.rb b/features/steps/shared/project.rb index dae248b8b7e..345a28f27dc 100644 --- a/features/steps/shared/project.rb +++ b/features/steps/shared/project.rb @@ -166,11 +166,15 @@ module SharedProject end step 'I should see project "Internal"' do - expect(page).to have_content "Internal" + page.within '.js-projects-list-holder' do + expect(page).to have_content "Internal" + end end step 'I should not see project "Internal"' do - expect(page).not_to have_content "Internal" + page.within '.js-projects-list-holder' do + expect(page).not_to have_content "Internal" + end end step 'public project "Community"' do -- cgit v1.2.1 From 51481534cae759c1bd893e311effc8c346b77aa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=92=83=20Winnie=20=F0=9F=92=83?= Date: Sat, 4 Mar 2017 20:55:01 +0000 Subject: Re-enable GitLab pages job for GitLab EE --- .gitlab-ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 9006fef6a27..c0eed0f84b0 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -421,6 +421,7 @@ pages: - public only: - master@gitlab-org/gitlab-ce + - master@gitlab-org/gitlab-ee # Insurance in case a gem needed by one of our releases gets yanked from # rubygems.org in the future. -- cgit v1.2.1 From c33f09d2549d2228a5ac7ceb7cb099774fbd826e Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Sun, 5 Mar 2017 18:49:30 +0100 Subject: Update triggers API --- lib/api/entities.rb | 4 - lib/api/triggers.rb | 68 +++++++++++--- lib/api/v3/entities.rb | 8 ++ lib/api/v3/triggers.rb | 77 ++++++++++++++- spec/requests/api/v3/triggers_spec.rb | 171 ++++++++++++++++++++++++++++++++++ 5 files changed, 311 insertions(+), 17 deletions(-) diff --git a/lib/api/entities.rb b/lib/api/entities.rb index d2d21f5d03a..82d05d85ead 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -592,10 +592,6 @@ module API end end - class TriggerRequest < Grape::Entity - expose :id, :variables - end - class Runner < Grape::Entity expose :id expose :description diff --git a/lib/api/triggers.rb b/lib/api/triggers.rb index b7c9c5f2b7f..c324708a16d 100644 --- a/lib/api/triggers.rb +++ b/lib/api/triggers.rb @@ -6,7 +6,7 @@ module API requires :id, type: String, desc: 'The ID of a project' end resource :projects do - desc 'Trigger a GitLab project build' do + desc 'Trigger a GitLab project pipeline' do success Entities::TriggerRequest end params do @@ -14,7 +14,7 @@ module API requires :token, type: String, desc: 'The unique token of trigger' optional :variables, type: Hash, desc: 'The list of variables to be injected into build' end - post ":id/(ref/:ref/)trigger/builds" do + post ":id/(ref/:ref/)trigger/pipeline" do project = find_project(params[:id]) trigger = Ci::Trigger.find_by_token(params[:token].to_s) not_found! unless project && trigger @@ -29,9 +29,9 @@ module API # create request and trigger builds trigger_request = Ci::CreateTriggerRequestService.new.execute(project, trigger, params[:ref].to_s, variables) if trigger_request - present trigger_request, with: Entities::TriggerRequest + present trigger_request.pipeline, with: Entities::Pipeline else - errors = 'No builds created' + errors = 'No pipeline create' render_api_error!(errors, 400) end end @@ -55,13 +55,13 @@ module API success Entities::Trigger end params do - requires :token, type: String, desc: 'The unique token of trigger' + requires :trigger_id, type: Integer, desc: 'The trigger ID' end - get ':id/triggers/:token' do + get ':id/triggers/:trigger_id' do authenticate! authorize! :admin_build, user_project - trigger = user_project.triggers.find_by(token: params[:token].to_s) + trigger = user_project.triggers.find(params[:trigger_id]) return not_found!('Trigger') unless trigger present trigger, with: Entities::Trigger @@ -70,26 +70,72 @@ module API desc 'Create a trigger' do success Entities::Trigger end + params do + requires :description, type: String, desc: 'The trigger description' + end post ':id/triggers' do authenticate! authorize! :admin_build, user_project - trigger = user_project.triggers.create + trigger = user_project.triggers.create( + declared_params(include_missing: false).merge(owner: current_user)) + + if trigger.valid? + present trigger, with: Entities::Trigger + else + render_validation_error!(trigger) + end + end + + desc 'Update a trigger' do + success Entities::Trigger + end + params do + requires :trigger_id, type: Integer, desc: 'The trigger ID' + optional :description, type: String, desc: 'The trigger description' + end + delete ':id/triggers/:trigger_id' do + authenticate! + authorize! :admin_build, user_project + + trigger = user_project.triggers.find(params[:trigger_id]) + return not_found!('Trigger') unless trigger + trigger = trigger.update(declared_params(include_missing: false)) present trigger, with: Entities::Trigger end + desc 'Take ownership of trigger' do + success Entities::Trigger + end + params do + requires :trigger_id, type: Integer, desc: 'The trigger ID' + end + post ':id/triggers/:trigger_id/take' do + authenticate! + authorize! :admin_build, user_project + + trigger = user_project.triggers.find(params[:trigger_id]) + return not_found!('Trigger') unless trigger + + if trigger.update(owner: current_user) + present trigger, with: Entities::Trigger + else + render_validation_error!(trigger) + end + end + desc 'Delete a trigger' do success Entities::Trigger end params do - requires :token, type: String, desc: 'The unique token of trigger' + requires :trigger_id, type: Integer, desc: 'The trigger ID' end - delete ':id/triggers/:token' do + delete ':id/triggers/:trigger_id' do authenticate! authorize! :admin_build, user_project - trigger = user_project.triggers.find_by(token: params[:token].to_s) + trigger = user_project.triggers.find(params[:trigger_id]) return not_found!('Trigger') unless trigger trigger.destroy diff --git a/lib/api/v3/entities.rb b/lib/api/v3/entities.rb index 270d99a2348..29a44d4c7e5 100644 --- a/lib/api/v3/entities.rb +++ b/lib/api/v3/entities.rb @@ -186,6 +186,14 @@ module API class Environment < ::API::Entities::EnvironmentBasic expose :project, using: Entities::Project end + + class Trigger < Grape::Entity + expose :token, :created_at, :updated_at, :deleted_at, :last_used + end + + class TriggerRequest < Grape::Entity + expose :id, :variables + end end end end diff --git a/lib/api/v3/triggers.rb b/lib/api/v3/triggers.rb index 4051d4bca8d..1dfdb6a5956 100644 --- a/lib/api/v3/triggers.rb +++ b/lib/api/v3/triggers.rb @@ -7,8 +7,81 @@ module API requires :id, type: String, desc: 'The ID of a project' end resource :projects do + desc 'Trigger a GitLab project build' do + success ::API::V3::Entities::TriggerRequest + end + params do + requires :ref, type: String, desc: 'The commit sha or name of a branch or tag' + requires :token, type: String, desc: 'The unique token of trigger' + optional :variables, type: Hash, desc: 'The list of variables to be injected into build' + end + post ":id/(ref/:ref/)trigger/builds" do + project = find_project(params[:id]) + trigger = Ci::Trigger.find_by_token(params[:token].to_s) + not_found! unless project && trigger + unauthorized! unless trigger.project == project + + # validate variables + variables = params[:variables].to_h + unless variables.all? { |key, value| key.is_a?(String) && value.is_a?(String) } + render_api_error!('variables needs to be a map of key-valued strings', 400) + end + + # create request and trigger builds + trigger_request = Ci::CreateTriggerRequestService.new.execute(project, trigger, params[:ref].to_s, variables) + if trigger_request + present trigger_request, with: ::API::V3::Entities::TriggerRequest + else + errors = 'No builds created' + render_api_error!(errors, 400) + end + end + + desc 'Get triggers list' do + success ::API::V3::Entities::Trigger + end + params do + use :pagination + end + get ':id/triggers' do + authenticate! + authorize! :admin_build, user_project + + triggers = user_project.triggers.includes(:trigger_requests) + + present paginate(triggers), with: ::API::V3::Entities::Trigger + end + + desc 'Get specific trigger of a project' do + success ::API::V3::Entities::Trigger + end + params do + requires :token, type: String, desc: 'The unique token of trigger' + end + get ':id/triggers/:token' do + authenticate! + authorize! :admin_build, user_project + + trigger = user_project.triggers.find_by(token: params[:token].to_s) + return not_found!('Trigger') unless trigger + + present trigger, with: ::API::V3::Entities::Trigger + end + + desc 'Create a trigger' do + success ::API::V3::Entities::Trigger + end + post ':id/triggers' do + authenticate! + authorize! :admin_build, user_project + + trigger = user_project.triggers.create + + present trigger, with: ::API::V3::Entities::Trigger + end + desc 'Delete a trigger' do - success ::API::Entities::Trigger + success ::API::V3::Entities::Trigger end params do requires :token, type: String, desc: 'The unique token of trigger' @@ -22,7 +95,7 @@ module API trigger.destroy - present trigger, with: ::API::Entities::Trigger + present trigger, with: ::API::V3::Entities::Trigger end end end diff --git a/spec/requests/api/v3/triggers_spec.rb b/spec/requests/api/v3/triggers_spec.rb index 721ce4a361b..4819269d69f 100644 --- a/spec/requests/api/v3/triggers_spec.rb +++ b/spec/requests/api/v3/triggers_spec.rb @@ -11,6 +11,177 @@ describe API::V3::Triggers do let!(:developer) { create(:project_member, :developer, user: user2, project: project) } let!(:trigger) { create(:ci_trigger, project: project, token: trigger_token) } + describe 'POST /projects/:project_id/trigger' do + let!(:project2) { create(:project) } + let(:options) do + { + token: trigger_token + } + end + + before do + stub_ci_pipeline_to_return_yaml_file + end + + context 'Handles errors' do + it 'returns bad request if token is missing' do + post v3_api("/projects/#{project.id}/trigger/builds"), ref: 'master' + expect(response).to have_http_status(400) + end + + it 'returns not found if project is not found' do + post v3_api('/projects/0/trigger/builds'), options.merge(ref: 'master') + expect(response).to have_http_status(404) + end + + it 'returns unauthorized if token is for different project' do + post v3_api("/projects/#{project2.id}/trigger/builds"), options.merge(ref: 'master') + expect(response).to have_http_status(401) + end + end + + context 'Have a commit' do + let(:pipeline) { project.pipelines.last } + + it 'creates builds' do + post v3_api("/projects/#{project.id}/trigger/builds"), options.merge(ref: 'master') + expect(response).to have_http_status(201) + pipeline.builds.reload + expect(pipeline.builds.pending.size).to eq(2) + expect(pipeline.builds.size).to eq(5) + end + + it 'creates builds on webhook from other gitlab repository and branch' do + expect do + post v3_api("/projects/#{project.id}/ref/master/trigger/builds?token=#{trigger_token}"), { ref: 'refs/heads/other-branch' } + end.to change(project.builds, :count).by(5) + expect(response).to have_http_status(201) + end + + it 'returns bad request with no builds created if there\'s no commit for that ref' do + post v3_api("/projects/#{project.id}/trigger/builds"), options.merge(ref: 'other-branch') + expect(response).to have_http_status(400) + expect(json_response['message']).to eq('No builds created') + end + + context 'Validates variables' do + let(:variables) do + { 'TRIGGER_KEY' => 'TRIGGER_VALUE' } + end + + it 'validates variables to be a hash' do + post v3_api("/projects/#{project.id}/trigger/builds"), options.merge(variables: 'value', ref: 'master') + expect(response).to have_http_status(400) + expect(json_response['error']).to eq('variables is invalid') + end + + it 'validates variables needs to be a map of key-valued strings' do + post v3_api("/projects/#{project.id}/trigger/builds"), options.merge(variables: { key: %w(1 2) }, ref: 'master') + expect(response).to have_http_status(400) + expect(json_response['message']).to eq('variables needs to be a map of key-valued strings') + end + + it 'creates trigger request with variables' do + post v3_api("/projects/#{project.id}/trigger/builds"), options.merge(variables: variables, ref: 'master') + expect(response).to have_http_status(201) + pipeline.builds.reload + expect(pipeline.builds.first.trigger_request.variables).to eq(variables) + end + end + end + end + + describe 'GET /projects/:id/triggers' do + context 'authenticated user with valid permissions' do + it 'returns list of triggers' do + get v3_api("/projects/#{project.id}/triggers", user) + + expect(response).to have_http_status(200) + expect(response).to include_pagination_headers + expect(json_response).to be_a(Array) + expect(json_response[0]).to have_key('token') + end + end + + context 'authenticated user with invalid permissions' do + it 'does not return triggers list' do + get v3_api("/projects/#{project.id}/triggers", user2) + + expect(response).to have_http_status(403) + end + end + + context 'unauthenticated user' do + it 'does not return triggers list' do + get v3_api("/projects/#{project.id}/triggers") + + expect(response).to have_http_status(401) + end + end + end + + describe 'GET /projects/:id/triggers/:token' do + context 'authenticated user with valid permissions' do + it 'returns trigger details' do + get v3_api("/projects/#{project.id}/triggers/#{trigger.token}", user) + + expect(response).to have_http_status(200) + expect(json_response).to be_a(Hash) + end + + it 'responds with 404 Not Found if requesting non-existing trigger' do + get v3_api("/projects/#{project.id}/triggers/abcdef012345", user) + + expect(response).to have_http_status(404) + end + end + + context 'authenticated user with invalid permissions' do + it 'does not return triggers list' do + get v3_api("/projects/#{project.id}/triggers/#{trigger.token}", user2) + + expect(response).to have_http_status(403) + end + end + + context 'unauthenticated user' do + it 'does not return triggers list' do + get v3_api("/projects/#{project.id}/triggers/#{trigger.token}") + + expect(response).to have_http_status(401) + end + end + end + + describe 'POST /projects/:id/triggers' do + context 'authenticated user with valid permissions' do + it 'creates trigger' do + expect do + post v3_api("/projects/#{project.id}/triggers", user) + end.to change{project.triggers.count}.by(1) + + expect(response).to have_http_status(201) + expect(json_response).to be_a(Hash) + end + end + + context 'authenticated user with invalid permissions' do + it 'does not create trigger' do + post v3_api("/projects/#{project.id}/triggers", user2) + + expect(response).to have_http_status(403) + end + end + + context 'unauthenticated user' do + it 'does not create trigger' do + post v3_api("/projects/#{project.id}/triggers") + + expect(response).to have_http_status(401) + end + end + end + describe 'DELETE /projects/:id/triggers/:token' do context 'authenticated user with valid permissions' do it 'deletes trigger' do -- cgit v1.2.1 From 0a75de2909152351483b317024fcab2e9bb08e16 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Wed, 15 Feb 2017 19:36:31 +0100 Subject: Make Pipeline Triggers to be user aware - they can have owner, - they can be edited, - they have description, - you can take ownership of them --- app/models/ci/trigger.rb | 12 +++++++++--- db/migrate/20170215164610_add_owner_id_to_triggers.rb | 10 ++++++++++ db/migrate/20170215165036_add_description_to_triggers.rb | 9 +++++++++ 3 files changed, 28 insertions(+), 3 deletions(-) create mode 100644 db/migrate/20170215164610_add_owner_id_to_triggers.rb create mode 100644 db/migrate/20170215165036_add_description_to_triggers.rb diff --git a/app/models/ci/trigger.rb b/app/models/ci/trigger.rb index 39a1dd86241..f76f06eb9c6 100644 --- a/app/models/ci/trigger.rb +++ b/app/models/ci/trigger.rb @@ -5,10 +5,12 @@ module Ci acts_as_paranoid belongs_to :project, foreign_key: :gl_project_id + belongs_to :owner, class_name: "User" + has_many :trigger_requests, dependent: :destroy - validates :token, presence: true - validates :token, uniqueness: true + validates :token, presence: true, uniqueness: true + validates :owner, presence: true before_validation :set_default_values @@ -25,7 +27,11 @@ module Ci end def short_token - token[0...10] + token[0...4] + end + + def can_show_token?(user) + owner.blank? || owner == user end end end diff --git a/db/migrate/20170215164610_add_owner_id_to_triggers.rb b/db/migrate/20170215164610_add_owner_id_to_triggers.rb new file mode 100644 index 00000000000..02c77ee4b5e --- /dev/null +++ b/db/migrate/20170215164610_add_owner_id_to_triggers.rb @@ -0,0 +1,10 @@ +class AddOwnerIdToTriggers < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + add_column :ci_triggers, :owner_id, :integer + add_foreign_key :ci_triggers, :users, column: :owner_id, on_delete: :nullify + end +end diff --git a/db/migrate/20170215165036_add_description_to_triggers.rb b/db/migrate/20170215165036_add_description_to_triggers.rb new file mode 100644 index 00000000000..1dca0e37412 --- /dev/null +++ b/db/migrate/20170215165036_add_description_to_triggers.rb @@ -0,0 +1,9 @@ +class AddDescriptionToTriggers < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + add_column :ci_triggers, :description, :string + end +end -- cgit v1.2.1 From 91ce04678ea78f2042026e8584e5d1069853dbc4 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Sun, 5 Mar 2017 18:23:42 +0100 Subject: Make triggers to be user aware --- app/models/user.rb | 1 + app/services/ci/create_trigger_request_service.rb | 2 +- db/migrate/20170215164610_add_owner_id_to_triggers.rb | 2 +- spec/models/ci/trigger_spec.rb | 14 +++++++++++--- spec/models/user_spec.rb | 1 + .../services/ci/create_trigger_request_service_spec.rb | 18 ++++++++++++++++-- 6 files changed, 31 insertions(+), 7 deletions(-) diff --git a/app/models/user.rb b/app/models/user.rb index d3bb04060bf..dfba51d3b00 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -95,6 +95,7 @@ class User < ActiveRecord::Base has_many :todos, dependent: :destroy has_many :notification_settings, dependent: :destroy has_many :award_emoji, dependent: :destroy + has_many :triggers, dependent: :destroy, class_name: 'Ci::Trigger', foreign_key: :owner_id has_many :assigned_issues, dependent: :nullify, foreign_key: :assignee_id, class_name: "Issue" has_many :assigned_merge_requests, dependent: :nullify, foreign_key: :assignee_id, class_name: "MergeRequest" diff --git a/app/services/ci/create_trigger_request_service.rb b/app/services/ci/create_trigger_request_service.rb index 6af3c1ca5b1..dca5aa9f5d7 100644 --- a/app/services/ci/create_trigger_request_service.rb +++ b/app/services/ci/create_trigger_request_service.rb @@ -3,7 +3,7 @@ module Ci def execute(project, trigger, ref, variables = nil) trigger_request = trigger.trigger_requests.create(variables: variables) - pipeline = Ci::CreatePipelineService.new(project, nil, ref: ref). + pipeline = Ci::CreatePipelineService.new(project, trigger.owner, ref: ref). execute(ignore_skip_ci: true, trigger_request: trigger_request) if pipeline.persisted? trigger_request diff --git a/db/migrate/20170215164610_add_owner_id_to_triggers.rb b/db/migrate/20170215164610_add_owner_id_to_triggers.rb index 02c77ee4b5e..845c89c703e 100644 --- a/db/migrate/20170215164610_add_owner_id_to_triggers.rb +++ b/db/migrate/20170215164610_add_owner_id_to_triggers.rb @@ -5,6 +5,6 @@ class AddOwnerIdToTriggers < ActiveRecord::Migration def change add_column :ci_triggers, :owner_id, :integer - add_foreign_key :ci_triggers, :users, column: :owner_id, on_delete: :nullify + add_foreign_key :ci_triggers, :users, column: :owner_id, on_delete: :cascade end end diff --git a/spec/models/ci/trigger_spec.rb b/spec/models/ci/trigger_spec.rb index 3ca9231f58e..074cf1a0bd7 100644 --- a/spec/models/ci/trigger_spec.rb +++ b/spec/models/ci/trigger_spec.rb @@ -1,16 +1,24 @@ require 'spec_helper' describe Ci::Trigger, models: true do - let(:project) { FactoryGirl.create :empty_project } + let(:project) { create :empty_project } + + describe 'associations' do + it { is_expected.to belong_to(:project) } + it { is_expected.to belong_to(:owner) } + it { is_expected.to have_many(:trigger_requests) } + end describe 'before_validation' do it 'sets an random token if none provided' do - trigger = FactoryGirl.create :ci_trigger_without_token, project: project + trigger = create(:ci_trigger_without_token, project: project) + expect(trigger.token).not_to be_nil end it 'does not set an random token if one provided' do - trigger = FactoryGirl.create :ci_trigger, project: project + trigger = create(:ci_trigger, project: project) + expect(trigger.token).to eq('token') end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index e86b4a761d9..b99cde64675 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -32,6 +32,7 @@ describe User, models: true do it { is_expected.to have_many(:spam_logs).dependent(:destroy) } it { is_expected.to have_many(:todos).dependent(:destroy) } it { is_expected.to have_many(:award_emoji).dependent(:destroy) } + it { is_expected.to have_many(:triggers).dependent(:destroy) } it { is_expected.to have_many(:builds).dependent(:nullify) } it { is_expected.to have_many(:pipelines).dependent(:nullify) } it { is_expected.to have_many(:chat_names).dependent(:destroy) } diff --git a/spec/services/ci/create_trigger_request_service_spec.rb b/spec/services/ci/create_trigger_request_service_spec.rb index d8c443d29d5..5e68343784d 100644 --- a/spec/services/ci/create_trigger_request_service_spec.rb +++ b/spec/services/ci/create_trigger_request_service_spec.rb @@ -13,8 +13,22 @@ describe Ci::CreateTriggerRequestService, services: true do context 'valid params' do subject { service.execute(project, trigger, 'master') } - it { expect(subject).to be_kind_of(Ci::TriggerRequest) } - it { expect(subject.builds.first).to be_kind_of(Ci::Build) } + context 'without owner' do + it { expect(subject).to be_kind_of(Ci::TriggerRequest) } + it { expect(subject.pipeline).to be_kind_of(Ci::Pipeline) } + it { expect(subject.builds.first).to be_kind_of(Ci::Build) } + end + + context 'with owner' do + let(:owner) { create(:user) } + let(:trigger) { create(:ci_trigger, project: project, owner: owner) } + + it { expect(subject).to be_kind_of(Ci::TriggerRequest) } + it { expect(subject.pipeline).to be_kind_of(Ci::Pipeline) } + it { expect(subject.pipeline.user).to eq(owner) } + it { expect(subject.builds.first).to be_kind_of(Ci::Build) } + it { expect(subject.builds.first.user).to eq(owner) } + end end context 'no commit for ref' do -- cgit v1.2.1 From 4ac1467c5ce969e082d96e7b2a60ae8e1654c881 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Sun, 5 Mar 2017 18:28:32 +0100 Subject: Update db/schema --- db/migrate/20170215164610_add_owner_id_to_triggers.rb | 10 ---------- db/migrate/20170215165036_add_description_to_triggers.rb | 9 --------- db/migrate/20170217151948_add_owner_id_to_triggers.rb | 10 ++++++++++ db/migrate/20170217151949_add_description_to_triggers.rb | 9 +++++++++ db/schema.rb | 9 ++++++--- 5 files changed, 25 insertions(+), 22 deletions(-) delete mode 100644 db/migrate/20170215164610_add_owner_id_to_triggers.rb delete mode 100644 db/migrate/20170215165036_add_description_to_triggers.rb create mode 100644 db/migrate/20170217151948_add_owner_id_to_triggers.rb create mode 100644 db/migrate/20170217151949_add_description_to_triggers.rb diff --git a/db/migrate/20170215164610_add_owner_id_to_triggers.rb b/db/migrate/20170215164610_add_owner_id_to_triggers.rb deleted file mode 100644 index 845c89c703e..00000000000 --- a/db/migrate/20170215164610_add_owner_id_to_triggers.rb +++ /dev/null @@ -1,10 +0,0 @@ -class AddOwnerIdToTriggers < ActiveRecord::Migration - include Gitlab::Database::MigrationHelpers - - DOWNTIME = false - - def change - add_column :ci_triggers, :owner_id, :integer - add_foreign_key :ci_triggers, :users, column: :owner_id, on_delete: :cascade - end -end diff --git a/db/migrate/20170215165036_add_description_to_triggers.rb b/db/migrate/20170215165036_add_description_to_triggers.rb deleted file mode 100644 index 1dca0e37412..00000000000 --- a/db/migrate/20170215165036_add_description_to_triggers.rb +++ /dev/null @@ -1,9 +0,0 @@ -class AddDescriptionToTriggers < ActiveRecord::Migration - include Gitlab::Database::MigrationHelpers - - DOWNTIME = false - - def change - add_column :ci_triggers, :description, :string - end -end diff --git a/db/migrate/20170217151948_add_owner_id_to_triggers.rb b/db/migrate/20170217151948_add_owner_id_to_triggers.rb new file mode 100644 index 00000000000..845c89c703e --- /dev/null +++ b/db/migrate/20170217151948_add_owner_id_to_triggers.rb @@ -0,0 +1,10 @@ +class AddOwnerIdToTriggers < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + add_column :ci_triggers, :owner_id, :integer + add_foreign_key :ci_triggers, :users, column: :owner_id, on_delete: :cascade + end +end diff --git a/db/migrate/20170217151949_add_description_to_triggers.rb b/db/migrate/20170217151949_add_description_to_triggers.rb new file mode 100644 index 00000000000..1dca0e37412 --- /dev/null +++ b/db/migrate/20170217151949_add_description_to_triggers.rb @@ -0,0 +1,9 @@ +class AddDescriptionToTriggers < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + add_column :ci_triggers, :description, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index cd5aa339269..75daae86a4a 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20170217151947) do +ActiveRecord::Schema.define(version: 20170217151949) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -111,7 +111,7 @@ ActiveRecord::Schema.define(version: 20170217151947) do t.boolean "plantuml_enabled" t.integer "max_pages_size", default: 100, null: false t.integer "terminal_max_session_time", default: 0, null: false - t.string "default_artifacts_expire_in", default: '0', null: false + t.string "default_artifacts_expire_in", default: "0", null: false end create_table "audit_events", force: :cascade do |t| @@ -377,6 +377,8 @@ ActiveRecord::Schema.define(version: 20170217151947) do t.datetime "created_at" t.datetime "updated_at" t.integer "gl_project_id" + t.integer "owner_id" + t.string "description" end add_index "ci_triggers", ["gl_project_id"], name: "index_ci_triggers_on_gl_project_id", using: :btree @@ -581,9 +583,9 @@ ActiveRecord::Schema.define(version: 20170217151947) do end add_index "labels", ["group_id", "project_id", "title"], name: "index_labels_on_group_id_and_project_id_and_title", unique: true, using: :btree - add_index "labels", ["type", "project_id"], name: "index_labels_on_type_and_project_id", using: :btree add_index "labels", ["project_id"], name: "index_labels_on_project_id", using: :btree add_index "labels", ["title"], name: "index_labels_on_title", using: :btree + add_index "labels", ["type", "project_id"], name: "index_labels_on_type_and_project_id", using: :btree create_table "lfs_objects", force: :cascade do |t| t.string "oid", null: false @@ -1333,6 +1335,7 @@ ActiveRecord::Schema.define(version: 20170217151947) do add_index "web_hooks", ["project_id"], name: "index_web_hooks_on_project_id", using: :btree add_foreign_key "boards", "projects" + add_foreign_key "ci_triggers", "users", column: "owner_id", on_delete: :cascade add_foreign_key "issue_metrics", "issues", on_delete: :cascade add_foreign_key "label_priorities", "labels", on_delete: :cascade add_foreign_key "label_priorities", "projects", on_delete: :cascade -- cgit v1.2.1 From ab972295bc0ec6db90ae451904a867788d66f49d Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Sun, 5 Mar 2017 18:30:58 +0100 Subject: Fix trigger model --- app/models/ci/trigger.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/app/models/ci/trigger.rb b/app/models/ci/trigger.rb index f76f06eb9c6..8aa45b2f02e 100644 --- a/app/models/ci/trigger.rb +++ b/app/models/ci/trigger.rb @@ -10,7 +10,6 @@ module Ci has_many :trigger_requests, dependent: :destroy validates :token, presence: true, uniqueness: true - validates :owner, presence: true before_validation :set_default_values -- cgit v1.2.1 From 4cd2ab52548e89cd7259cfb7ce320fdfa203fe84 Mon Sep 17 00:00:00 2001 From: winniehell Date: Tue, 14 Feb 2017 20:10:57 +0100 Subject: Adjust ESLint rule for file names --- .eslintrc | 2 +- changelogs/unreleased/remove-es6-extension.yml | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 changelogs/unreleased/remove-es6-extension.yml diff --git a/.eslintrc b/.eslintrc index 0fcd866778f..b0ae2a31919 100644 --- a/.eslintrc +++ b/.eslintrc @@ -23,7 +23,7 @@ } }, "rules": { - "filenames/match-regex": [2, "^[a-z0-9_]+(.js)?$"], + "filenames/match-regex": [2, "^[a-z0-9_]+$"], "no-multiple-empty-lines": ["error", { "max": 1 }] } } diff --git a/changelogs/unreleased/remove-es6-extension.yml b/changelogs/unreleased/remove-es6-extension.yml new file mode 100644 index 00000000000..65f4a7a7867 --- /dev/null +++ b/changelogs/unreleased/remove-es6-extension.yml @@ -0,0 +1,4 @@ +--- +title: Remove es6 file extension from JavaScript files +merge_request: 9241 +author: winniehell -- cgit v1.2.1 From 140b51ce980bc519f3478bf4321dfd4a35d6bd3c Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Sun, 5 Mar 2017 20:58:08 +0100 Subject: Introduce tests for pipeline triggers --- app/helpers/triggers_helper.rb | 4 +- .../unreleased/introduce-pipeline-triggers.yml | 4 + doc/ci/triggers/README.md | 16 +-- lib/api/entities.rb | 4 +- lib/api/triggers.rb | 22 +-- spec/requests/api/triggers_spec.rb | 147 ++++++++++++++++----- 6 files changed, 145 insertions(+), 52 deletions(-) create mode 100644 changelogs/unreleased/introduce-pipeline-triggers.yml diff --git a/app/helpers/triggers_helper.rb b/app/helpers/triggers_helper.rb index b0135ea2e95..a48d4475e97 100644 --- a/app/helpers/triggers_helper.rb +++ b/app/helpers/triggers_helper.rb @@ -1,9 +1,9 @@ module TriggersHelper def builds_trigger_url(project_id, ref: nil) if ref.nil? - "#{Settings.gitlab.url}/api/v3/projects/#{project_id}/trigger/builds" + "#{Settings.gitlab.url}/api/v4/projects/#{project_id}/trigger/pipeline" else - "#{Settings.gitlab.url}/api/v3/projects/#{project_id}/ref/#{ref}/trigger/builds" + "#{Settings.gitlab.url}/api/v4/projects/#{project_id}/ref/#{ref}/trigger/pipeline" end end diff --git a/changelogs/unreleased/introduce-pipeline-triggers.yml b/changelogs/unreleased/introduce-pipeline-triggers.yml new file mode 100644 index 00000000000..ce5a230d48f --- /dev/null +++ b/changelogs/unreleased/introduce-pipeline-triggers.yml @@ -0,0 +1,4 @@ +--- +title: Introduce Pipeline Triggers that are user-aware +merge_request: +author: diff --git a/doc/ci/triggers/README.md b/doc/ci/triggers/README.md index 1ad9621c8a0..ccaee33dc92 100644 --- a/doc/ci/triggers/README.md +++ b/doc/ci/triggers/README.md @@ -36,7 +36,7 @@ it will not trigger a job. To trigger a job you need to send a `POST` request to GitLab's API endpoint: ``` -POST /projects/:id/trigger/builds +POST /projects/:id/trigger/pipeline ``` The required parameters are the trigger's `token` and the Git `ref` on which @@ -71,7 +71,7 @@ To trigger a job from webhook of another project you need to add the following webhook url for Push and Tag push events: ``` -https://gitlab.example.com/api/v4/projects/:id/ref/:ref/trigger/builds?token=TOKEN +https://gitlab.example.com/api/v4/projects/:id/ref/:ref/trigger/pipeline?token=TOKEN ``` > **Note**: @@ -105,7 +105,7 @@ Using cURL you can trigger a rebuild with minimal effort, for example: curl --request POST \ --form token=TOKEN \ --form ref=master \ - https://gitlab.example.com/api/v4/projects/9/trigger/builds + https://gitlab.example.com/api/v4/projects/9/trigger/pipeline ``` In this case, the project with ID `9` will get rebuilt on `master` branch. @@ -114,7 +114,7 @@ Alternatively, you can pass the `token` and `ref` arguments in the query string: ```bash curl --request POST \ - "https://gitlab.example.com/api/v4/projects/9/trigger/builds?token=TOKEN&ref=master" + "https://gitlab.example.com/api/v4/projects/9/trigger/pipeline?token=TOKEN&ref=master" ``` ### Triggering a job within `.gitlab-ci.yml` @@ -128,7 +128,7 @@ need to add in project's A `.gitlab-ci.yml`: build_docs: stage: deploy script: - - "curl --request POST --form token=TOKEN --form ref=master https://gitlab.example.com/api/v4/projects/9/trigger/builds" + - "curl --request POST --form token=TOKEN --form ref=master https://gitlab.example.com/api/v4/projects/9/trigger/pipeline" only: - tags ``` @@ -187,7 +187,7 @@ curl --request POST \ --form token=TOKEN \ --form ref=master \ --form "variables[UPLOAD_TO_S3]=true" \ - https://gitlab.example.com/api/v4/projects/9/trigger/builds + https://gitlab.example.com/api/v4/projects/9/trigger/pipeline ``` ### Using webhook to trigger job @@ -195,7 +195,7 @@ curl --request POST \ You can add the following webhook to another project in order to trigger a job: ``` -https://gitlab.example.com/api/v4/projects/9/ref/master/trigger/builds?token=TOKEN&variables[UPLOAD_TO_S3]=true +https://gitlab.example.com/api/v4/projects/9/ref/master/trigger/pipeline?token=TOKEN&variables[UPLOAD_TO_S3]=true ``` ### Using cron to trigger nightly jobs @@ -205,7 +205,7 @@ in conjunction with cron. The example below triggers a job on the `master` branch of project with ID `9` every night at `00:30`: ```bash -30 0 * * * curl --request POST --form token=TOKEN --form ref=master https://gitlab.example.com/api/v4/projects/9/trigger/builds +30 0 * * * curl --request POST --form token=TOKEN --form ref=master https://gitlab.example.com/api/v4/projects/9/trigger/pipeline ``` [ci-229]: https://gitlab.com/gitlab-org/gitlab-ci/merge_requests/229 diff --git a/lib/api/entities.rb b/lib/api/entities.rb index 82d05d85ead..c0c94044ced 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -639,7 +639,9 @@ module API end class Trigger < Grape::Entity - expose :token, :created_at, :updated_at, :deleted_at, :last_used + expose :token, :description + expose :created_at, :updated_at, :deleted_at, :last_used + expose :owner, using: Entities::UserBasic end class Variable < Grape::Entity diff --git a/lib/api/triggers.rb b/lib/api/triggers.rb index c324708a16d..157f3cef1fd 100644 --- a/lib/api/triggers.rb +++ b/lib/api/triggers.rb @@ -7,7 +7,7 @@ module API end resource :projects do desc 'Trigger a GitLab project pipeline' do - success Entities::TriggerRequest + success Entities::Pipeline end params do requires :ref, type: String, desc: 'The commit sha or name of a branch or tag' @@ -31,7 +31,7 @@ module API if trigger_request present trigger_request.pipeline, with: Entities::Pipeline else - errors = 'No pipeline create' + errors = 'No pipeline created' render_api_error!(errors, 400) end end @@ -61,7 +61,7 @@ module API authenticate! authorize! :admin_build, user_project - trigger = user_project.triggers.find(params[:trigger_id]) + trigger = user_project.triggers.find(params.delete(:trigger_id)) return not_found!('Trigger') unless trigger present trigger, with: Entities::Trigger @@ -94,15 +94,18 @@ module API requires :trigger_id, type: Integer, desc: 'The trigger ID' optional :description, type: String, desc: 'The trigger description' end - delete ':id/triggers/:trigger_id' do + put ':id/triggers/:trigger_id' do authenticate! authorize! :admin_build, user_project - trigger = user_project.triggers.find(params[:trigger_id]) + trigger = user_project.triggers.find(params.delete(:trigger_id)) return not_found!('Trigger') unless trigger - trigger = trigger.update(declared_params(include_missing: false)) - present trigger, with: Entities::Trigger + if trigger.update(declared_params(include_missing: false)) + present trigger, with: Entities::Trigger + else + render_validation_error!(trigger) + end end desc 'Take ownership of trigger' do @@ -115,10 +118,11 @@ module API authenticate! authorize! :admin_build, user_project - trigger = user_project.triggers.find(params[:trigger_id]) + trigger = user_project.triggers.find(params.delete(:trigger_id)) return not_found!('Trigger') unless trigger if trigger.update(owner: current_user) + status :ok present trigger, with: Entities::Trigger else render_validation_error!(trigger) @@ -135,7 +139,7 @@ module API authenticate! authorize! :admin_build, user_project - trigger = user_project.triggers.find(params[:trigger_id]) + trigger = user_project.triggers.find(params.delete(:trigger_id)) return not_found!('Trigger') unless trigger trigger.destroy diff --git a/spec/requests/api/triggers_spec.rb b/spec/requests/api/triggers_spec.rb index 153e2791cbe..f2effd71755 100644 --- a/spec/requests/api/triggers_spec.rb +++ b/spec/requests/api/triggers_spec.rb @@ -14,7 +14,7 @@ describe API::Triggers do let!(:trigger2) { create(:ci_trigger, project: project, token: trigger_token_2) } let!(:trigger_request) { create(:ci_trigger_request, trigger: trigger, created_at: '2015-01-01 12:13:14') } - describe 'POST /projects/:project_id/trigger' do + describe 'POST /projects/:project_id/trigger/pipeline' do let!(:project2) { create(:project) } let(:options) do { @@ -28,17 +28,20 @@ describe API::Triggers do context 'Handles errors' do it 'returns bad request if token is missing' do - post api("/projects/#{project.id}/trigger/builds"), ref: 'master' + post api("/projects/#{project.id}/trigger/pipeline"), ref: 'master' + expect(response).to have_http_status(400) end it 'returns not found if project is not found' do - post api('/projects/0/trigger/builds'), options.merge(ref: 'master') + post api('/projects/0/trigger/pipeline'), options.merge(ref: 'master') + expect(response).to have_http_status(404) end it 'returns unauthorized if token is for different project' do - post api("/projects/#{project2.id}/trigger/builds"), options.merge(ref: 'master') + post api("/projects/#{project2.id}/trigger/pipeline"), options.merge(ref: 'master') + expect(response).to have_http_status(401) end end @@ -46,9 +49,11 @@ describe API::Triggers do context 'Have a commit' do let(:pipeline) { project.pipelines.last } - it 'creates builds' do - post api("/projects/#{project.id}/trigger/builds"), options.merge(ref: 'master') + it 'creates pipeline' do + post api("/projects/#{project.id}/trigger/pipeline"), options.merge(ref: 'master') + expect(response).to have_http_status(201) + expect(json_response).to include('id' => pipeline.id) pipeline.builds.reload expect(pipeline.builds.pending.size).to eq(2) expect(pipeline.builds.size).to eq(5) @@ -56,15 +61,17 @@ describe API::Triggers do it 'creates builds on webhook from other gitlab repository and branch' do expect do - post api("/projects/#{project.id}/ref/master/trigger/builds?token=#{trigger_token}"), { ref: 'refs/heads/other-branch' } + post api("/projects/#{project.id}/ref/master/trigger/pipeline?token=#{trigger_token}"), { ref: 'refs/heads/other-branch' } end.to change(project.builds, :count).by(5) + expect(response).to have_http_status(201) end - it 'returns bad request with no builds created if there\'s no commit for that ref' do - post api("/projects/#{project.id}/trigger/builds"), options.merge(ref: 'other-branch') + it 'returns bad request with no pipeline created if there\'s no commit for that ref' do + post api("/projects/#{project.id}/trigger/pipeline"), options.merge(ref: 'other-branch') + expect(response).to have_http_status(400) - expect(json_response['message']).to eq('No builds created') + expect(json_response['message']).to eq('No pipeline created') end context 'Validates variables' do @@ -73,22 +80,24 @@ describe API::Triggers do end it 'validates variables to be a hash' do - post api("/projects/#{project.id}/trigger/builds"), options.merge(variables: 'value', ref: 'master') + post api("/projects/#{project.id}/trigger/pipeline"), options.merge(variables: 'value', ref: 'master') + expect(response).to have_http_status(400) expect(json_response['error']).to eq('variables is invalid') end it 'validates variables needs to be a map of key-valued strings' do - post api("/projects/#{project.id}/trigger/builds"), options.merge(variables: { key: %w(1 2) }, ref: 'master') + post api("/projects/#{project.id}/trigger/pipeline"), options.merge(variables: { key: %w(1 2) }, ref: 'master') + expect(response).to have_http_status(400) expect(json_response['message']).to eq('variables needs to be a map of key-valued strings') end it 'creates trigger request with variables' do - post api("/projects/#{project.id}/trigger/builds"), options.merge(variables: variables, ref: 'master') + post api("/projects/#{project.id}/trigger/pipeline"), options.merge(variables: variables, ref: 'master') + expect(response).to have_http_status(201) - pipeline.builds.reload - expect(pipeline.builds.first.trigger_request.variables).to eq(variables) + expect(pipeline.builds.reload.first.trigger_request.variables).to eq(variables) end end end @@ -123,17 +132,17 @@ describe API::Triggers do end end - describe 'GET /projects/:id/triggers/:token' do + describe 'GET /projects/:id/triggers/:trigger_id' do context 'authenticated user with valid permissions' do it 'returns trigger details' do - get api("/projects/#{project.id}/triggers/#{trigger.token}", user) + get api("/projects/#{project.id}/triggers/#{trigger.id}", user) expect(response).to have_http_status(200) expect(json_response).to be_a(Hash) end it 'responds with 404 Not Found if requesting non-existing trigger' do - get api("/projects/#{project.id}/triggers/abcdef012345", user) + get api("/projects/#{project.id}/triggers/-5", user) expect(response).to have_http_status(404) end @@ -141,7 +150,7 @@ describe API::Triggers do context 'authenticated user with invalid permissions' do it 'does not return triggers list' do - get api("/projects/#{project.id}/triggers/#{trigger.token}", user2) + get api("/projects/#{project.id}/triggers/#{trigger.id}", user2) expect(response).to have_http_status(403) end @@ -149,7 +158,7 @@ describe API::Triggers do context 'unauthenticated user' do it 'does not return triggers list' do - get api("/projects/#{project.id}/triggers/#{trigger.token}") + get api("/projects/#{project.id}/triggers/#{trigger.id}") expect(response).to have_http_status(401) end @@ -158,19 +167,31 @@ describe API::Triggers do describe 'POST /projects/:id/triggers' do context 'authenticated user with valid permissions' do - it 'creates trigger' do - expect do + context 'with required parameters' do + it 'creates trigger' do + expect do + post api("/projects/#{project.id}/triggers", user), + description: 'trigger' + end.to change{project.triggers.count}.by(1) + + expect(response).to have_http_status(201) + expect(json_response).to include('description' => 'trigger') + end + end + + context 'without required parameters' do + it 'creates trigger' do post api("/projects/#{project.id}/triggers", user) - end.to change{project.triggers.count}.by(1) - expect(response).to have_http_status(201) - expect(json_response).to be_a(Hash) + expect(response).to have_http_status(:bad_request) + end end end context 'authenticated user with invalid permissions' do it 'does not create trigger' do - post api("/projects/#{project.id}/triggers", user2) + post api("/projects/#{project.id}/triggers", user2), + description: 'trigger' expect(response).to have_http_status(403) end @@ -178,25 +199,87 @@ describe API::Triggers do context 'unauthenticated user' do it 'does not create trigger' do - post api("/projects/#{project.id}/triggers") + post api("/projects/#{project.id}/triggers"), + description: 'trigger' + + expect(response).to have_http_status(401) + end + end + end + + describe 'PUT /projects/:id/triggers/:trigger_id' do + context 'authenticated user with valid permissions' do + let(:new_description) { 'new description' } + + it 'updates description' do + put api("/projects/#{project.id}/triggers/#{trigger.id}", user), + description: new_description + + expect(response).to have_http_status(200) + expect(json_response).to include('description' => new_description) + expect(trigger.reload.description).to eq(new_description) + end + end + + context 'authenticated user with invalid permissions' do + it 'does not update trigger' do + put api("/projects/#{project.id}/triggers/#{trigger.id}", user2) + + expect(response).to have_http_status(403) + end + end + + context 'unauthenticated user' do + it 'does not update trigger' do + put api("/projects/#{project.id}/triggers/#{trigger.id}") + + expect(response).to have_http_status(401) + end + end + end + + describe 'POST /projects/:id/triggers/:trigger_id/take' do + context 'authenticated user with valid permissions' do + it 'updates owner' do + expect(trigger.owner).to be_nil + + post api("/projects/#{project.id}/triggers/#{trigger.id}/take", user) + + expect(response).to have_http_status(200) + expect(json_response).to include('owner') + expect(trigger.reload.owner).to eq(user) + end + end + + context 'authenticated user with invalid permissions' do + it 'does not update owner' do + post api("/projects/#{project.id}/triggers/#{trigger.id}/take", user2) + + expect(response).to have_http_status(403) + end + end + + context 'unauthenticated user' do + it 'does not update owner' do + post api("/projects/#{project.id}/triggers/#{trigger.id}/take") expect(response).to have_http_status(401) end end end - describe 'DELETE /projects/:id/triggers/:token' do + describe 'DELETE /projects/:id/triggers/:trigger_id' do context 'authenticated user with valid permissions' do it 'deletes trigger' do expect do - delete api("/projects/#{project.id}/triggers/#{trigger.token}", user) + delete api("/projects/#{project.id}/triggers/#{trigger.id}", user) expect(response).to have_http_status(204) end.to change{project.triggers.count}.by(-1) end it 'responds with 404 Not Found if requesting non-existing trigger' do - delete api("/projects/#{project.id}/triggers/abcdef012345", user) + delete api("/projects/#{project.id}/triggers/-5", user) expect(response).to have_http_status(404) end @@ -204,7 +287,7 @@ describe API::Triggers do context 'authenticated user with invalid permissions' do it 'does not delete trigger' do - delete api("/projects/#{project.id}/triggers/#{trigger.token}", user2) + delete api("/projects/#{project.id}/triggers/#{trigger.id}", user2) expect(response).to have_http_status(403) end @@ -212,7 +295,7 @@ describe API::Triggers do context 'unauthenticated user' do it 'does not delete trigger' do - delete api("/projects/#{project.id}/triggers/#{trigger.token}") + delete api("/projects/#{project.id}/triggers/#{trigger.id}") expect(response).to have_http_status(401) end -- cgit v1.2.1 From 307d12526bf7209569411995f9b4422a1ee8fff4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=92=83=20Winnie=20=F0=9F=92=83?= Date: Sun, 5 Mar 2017 20:08:10 +0000 Subject: Enable build job for caching Gems of GitLab EE --- .gitlab-ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index c0eed0f84b0..deeb01f9a3c 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -438,3 +438,4 @@ cache gems: - vendor/cache only: - master@gitlab-org/gitlab-ce + - master@gitlab-org/gitlab-ee -- cgit v1.2.1 From b565ee4912d742ef01d10e9a6fae64fe79d6b7bf Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Sun, 5 Mar 2017 21:18:00 +0100 Subject: Update documentation and expose ID --- doc/api/README.md | 2 +- doc/api/build_triggers.md | 108 --------------------------- doc/api/pipeline_triggers.md | 170 +++++++++++++++++++++++++++++++++++++++++++ lib/api/entities.rb | 1 + 4 files changed, 172 insertions(+), 109 deletions(-) delete mode 100644 doc/api/build_triggers.md create mode 100644 doc/api/pipeline_triggers.md diff --git a/doc/api/README.md b/doc/api/README.md index 3399e2bb5f6..285cd2435ac 100644 --- a/doc/api/README.md +++ b/doc/api/README.md @@ -12,7 +12,6 @@ following locations: - [Branches](branches.md) - [Broadcast Messages](broadcast_messages.md) - [Builds](builds.md) -- [Build Triggers](build_triggers.md) - [Build Variables](build_variables.md) - [Commits](commits.md) - [Deployments](deployments.md) @@ -33,6 +32,7 @@ following locations: - [Notes](notes.md) (comments) - [Notification settings](notification_settings.md) - [Pipelines](pipelines.md) +- [Pipeline Triggers](pipeline_triggers.md) - [Projects](projects.md) including setting Webhooks - [Project Access Requests](access_requests.md) - [Project Members](members.md) diff --git a/doc/api/build_triggers.md b/doc/api/build_triggers.md deleted file mode 100644 index 28befba69d6..00000000000 --- a/doc/api/build_triggers.md +++ /dev/null @@ -1,108 +0,0 @@ -# Build triggers - -You can read more about [triggering builds through the API](../ci/triggers/README.md). - -## List project triggers - -Get a list of project's build triggers. - -``` -GET /projects/:id/triggers -``` - -| Attribute | Type | required | Description | -|-----------|---------|----------|---------------------| -| `id` | integer | yes | The ID of a project | - -``` -curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/projects/1/triggers" -``` - -```json -[ - { - "created_at": "2015-12-23T16:24:34.716Z", - "deleted_at": null, - "last_used": "2016-01-04T15:41:21.986Z", - "token": "fbdb730c2fbdb095a0862dbd8ab88b", - "updated_at": "2015-12-23T16:24:34.716Z" - }, - { - "created_at": "2015-12-23T16:25:56.760Z", - "deleted_at": null, - "last_used": null, - "token": "7b9148c158980bbd9bcea92c17522d", - "updated_at": "2015-12-23T16:25:56.760Z" - } -] -``` - -## Get trigger details - -Get details of project's build trigger. - -``` -GET /projects/:id/triggers/:token -``` - -| Attribute | Type | required | Description | -|-----------|---------|----------|--------------------------| -| `id` | integer | yes | The ID of a project | -| `token` | string | yes | The `token` of a trigger | - -``` -curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/projects/1/triggers/7b9148c158980bbd9bcea92c17522d" -``` - -```json -{ - "created_at": "2015-12-23T16:25:56.760Z", - "deleted_at": null, - "last_used": null, - "token": "7b9148c158980bbd9bcea92c17522d", - "updated_at": "2015-12-23T16:25:56.760Z" -} -``` - -## Create a project trigger - -Create a build trigger for a project. - -``` -POST /projects/:id/triggers -``` - -| Attribute | Type | required | Description | -|-----------|---------|----------|--------------------------| -| `id` | integer | yes | The ID of a project | - -``` -curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/projects/1/triggers" -``` - -```json -{ - "created_at": "2016-01-07T09:53:58.235Z", - "deleted_at": null, - "last_used": null, - "token": "6d056f63e50fe6f8c5f8f4aa10edb7", - "updated_at": "2016-01-07T09:53:58.235Z" -} -``` - -## Remove a project trigger - -Remove a project's build trigger. - -``` -DELETE /projects/:id/triggers/:token -``` - -| Attribute | Type | required | Description | -|-----------|---------|----------|--------------------------| -| `id` | integer | yes | The ID of a project | -| `token` | string | yes | The `token` of a trigger | - -``` -curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/projects/1/triggers/7b9148c158980bbd9bcea92c17522d" -``` diff --git a/doc/api/pipeline_triggers.md b/doc/api/pipeline_triggers.md new file mode 100644 index 00000000000..aebc84b9a66 --- /dev/null +++ b/doc/api/pipeline_triggers.md @@ -0,0 +1,170 @@ +# Pipeline triggers + +You can read more about [triggering pipelines through the API](../ci/triggers/README.md). + +## List project triggers + +Get a list of project's build triggers. + +``` +GET /projects/:id/triggers +``` + +| Attribute | Type | required | Description | +|-----------|---------|----------|---------------------| +| `id` | integer | yes | The ID of a project | + +``` +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/projects/1/triggers" +``` + +```json +[ + { + "id": 10, + "description": "my trigger", + "created_at": "2016-01-07T09:53:58.235Z", + "deleted_at": null, + "last_used": null, + "token": "6d056f63e50fe6f8c5f8f4aa10edb7", + "updated_at": "2016-01-07T09:53:58.235Z", + "owner": null + } +] +``` + +## Get trigger details + +Get details of project's build trigger. + +``` +GET /projects/:id/triggers/:trigger_id +``` + +| Attribute | Type | required | Description | +|-----------|---------|----------|--------------------------| +| `id` | integer | yes | The ID of a project | +| `token` | string | yes | The `token` of a trigger | + +``` +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/projects/1/triggers/5" +``` + +```json +{ + "id": 10, + "description": "my trigger", + "created_at": "2016-01-07T09:53:58.235Z", + "deleted_at": null, + "last_used": null, + "token": "6d056f63e50fe6f8c5f8f4aa10edb7", + "updated_at": "2016-01-07T09:53:58.235Z", + "owner": null +} +``` + +## Create a project trigger + +Create a trigger for a project. + +``` +POST /projects/:id/triggers +``` + +| Attribute | Type | required | Description | +|---------------|---------|----------|--------------------------| +| `id` | integer | yes | The ID of a project | +| `description` | string | yes | The trigger name | + +``` +curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" -F description="my description" "https://gitlab.example.com/api/v4/projects/1/triggers" +``` + +```json +{ + "id": 10, + "description": "my trigger", + "created_at": "2016-01-07T09:53:58.235Z", + "deleted_at": null, + "last_used": null, + "token": "6d056f63e50fe6f8c5f8f4aa10edb7", + "updated_at": "2016-01-07T09:53:58.235Z", + "owner": null +} +``` + +## Update a project trigger + +Update a trigger for a project. + +``` +PUT /projects/:id/triggers/:trigger_id +``` + +| Attribute | Type | required | Description | +|---------------|---------|----------|--------------------------| +| `trigger_id` | integer | yes | The trigger id | +| `description` | string | no | The trigger name | + +``` +curl --request PUT -F description="my description" --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/projects/1/triggers/10" +``` + +```json +{ + "id": 10, + "description": "my trigger", + "created_at": "2016-01-07T09:53:58.235Z", + "deleted_at": null, + "last_used": null, + "token": "6d056f63e50fe6f8c5f8f4aa10edb7", + "updated_at": "2016-01-07T09:53:58.235Z", + "owner": null +} +``` + +## Take ownership of a project trigger + +Update an owner of a project trigger. + +``` +POST /projects/:id/triggers/:trigger_id/take +``` + +| Attribute | Type | required | Description | +|---------------|---------|----------|--------------------------| +| `trigger_id` | integer | yes | The trigger id | + +``` +curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/projects/1/triggers/10/take" +``` + +```json +{ + "id": 10, + "description": "my trigger", + "created_at": "2016-01-07T09:53:58.235Z", + "deleted_at": null, + "last_used": null, + "token": "6d056f63e50fe6f8c5f8f4aa10edb7", + "updated_at": "2016-01-07T09:53:58.235Z", + "owner": null +} +``` + +## Remove a project trigger + +Remove a project's build trigger. + +``` +DELETE /projects/:id/triggers/:trigger_id +``` + +| Attribute | Type | required | Description | +|----------------|---------|----------|--------------------------| +| `id` | integer | yes | The ID of a project | +| `trigger_id` | integer | yes | The trigger id | + +``` +curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/projects/1/triggers/5" +``` diff --git a/lib/api/entities.rb b/lib/api/entities.rb index c0c94044ced..98ef9d4118e 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -639,6 +639,7 @@ module API end class Trigger < Grape::Entity + expose :id expose :token, :description expose :created_at, :updated_at, :deleted_at, :last_used expose :owner, using: Entities::UserBasic -- cgit v1.2.1 From 572f9782d5e8d6307784b61db0dfce48f5118445 Mon Sep 17 00:00:00 2001 From: winniehell Date: Sun, 5 Mar 2017 20:43:05 +0100 Subject: Remove .es6 from file extensions (!9241) --- app/assets/javascripts/abuse_reports.js | 40 ++ app/assets/javascripts/abuse_reports.js.es6 | 40 -- app/assets/javascripts/activities.js | 37 ++ app/assets/javascripts/activities.js.es6 | 37 -- app/assets/javascripts/blob/blob_ci_yaml.js | 42 ++ app/assets/javascripts/blob/blob_ci_yaml.js.es6 | 42 -- .../javascripts/blob/blob_dockerfile_selector.js | 19 + .../blob/blob_dockerfile_selector.js.es6 | 19 - .../javascripts/blob/blob_dockerfile_selectors.js | 27 ++ .../blob/blob_dockerfile_selectors.js.es6 | 27 -- .../javascripts/blob/blob_license_selectors.js | 23 + .../javascripts/blob/blob_license_selectors.js.es6 | 23 - app/assets/javascripts/blob/template_selector.js | 101 ++++ .../javascripts/blob/template_selector.js.es6 | 101 ---- app/assets/javascripts/boards/boards_bundle.js | 151 ++++++ app/assets/javascripts/boards/boards_bundle.js.es6 | 151 ------ app/assets/javascripts/boards/components/board.js | 105 +++++ .../javascripts/boards/components/board.js.es6 | 105 ----- .../boards/components/board_blank_state.js | 53 +++ .../boards/components/board_blank_state.js.es6 | 53 --- .../javascripts/boards/components/board_delete.js | 22 + .../boards/components/board_delete.js.es6 | 22 - .../javascripts/boards/components/board_list.js | 129 ++++++ .../boards/components/board_list.js.es6 | 129 ------ .../javascripts/boards/components/board_sidebar.js | 72 +++ .../boards/components/board_sidebar.js.es6 | 72 --- .../boards/components/issue_card_inner.js | 111 +++++ .../boards/components/issue_card_inner.js.es6 | 111 ----- .../boards/components/modal/empty_state.js | 70 +++ .../boards/components/modal/empty_state.js.es6 | 70 --- .../javascripts/boards/components/modal/filters.js | 49 ++ .../boards/components/modal/filters.js.es6 | 49 -- .../boards/components/modal/filters/label.js | 54 +++ .../boards/components/modal/filters/label.js.es6 | 54 --- .../boards/components/modal/filters/milestone.js | 55 +++ .../components/modal/filters/milestone.js.es6 | 55 --- .../boards/components/modal/filters/user.js | 96 ++++ .../boards/components/modal/filters/user.js.es6 | 96 ---- .../javascripts/boards/components/modal/footer.js | 83 ++++ .../boards/components/modal/footer.js.es6 | 83 ---- .../javascripts/boards/components/modal/header.js | 90 ++++ .../boards/components/modal/header.js.es6 | 90 ---- .../javascripts/boards/components/modal/index.js | 163 +++++++ .../boards/components/modal/index.js.es6 | 163 ------- .../javascripts/boards/components/modal/list.js | 159 +++++++ .../boards/components/modal/list.js.es6 | 159 ------- .../boards/components/modal/lists_dropdown.js | 56 +++ .../boards/components/modal/lists_dropdown.js.es6 | 56 --- .../javascripts/boards/components/modal/tabs.js | 47 ++ .../boards/components/modal/tabs.js.es6 | 47 -- .../boards/components/new_list_dropdown.js | 76 +++ .../boards/components/new_list_dropdown.js.es6 | 76 --- .../boards/components/sidebar/remove_issue.js | 59 +++ .../boards/components/sidebar/remove_issue.js.es6 | 59 --- .../javascripts/boards/filters/due_date_filters.js | 7 + .../boards/filters/due_date_filters.js.es6 | 7 - .../javascripts/boards/mixins/modal_mixins.js | 14 + .../javascripts/boards/mixins/modal_mixins.js.es6 | 14 - .../boards/mixins/sortable_default_options.js | 39 ++ .../boards/mixins/sortable_default_options.js.es6 | 39 -- app/assets/javascripts/boards/models/issue.js | 78 ++++ app/assets/javascripts/boards/models/issue.js.es6 | 78 ---- app/assets/javascripts/boards/models/label.js | 14 + app/assets/javascripts/boards/models/label.js.es6 | 14 - app/assets/javascripts/boards/models/list.js | 156 +++++++ app/assets/javascripts/boards/models/list.js.es6 | 156 ------- app/assets/javascripts/boards/models/milestone.js | 10 + .../javascripts/boards/models/milestone.js.es6 | 10 - app/assets/javascripts/boards/models/user.js | 12 + app/assets/javascripts/boards/models/user.js.es6 | 12 - .../javascripts/boards/services/board_service.js | 95 ++++ .../boards/services/board_service.js.es6 | 95 ---- .../javascripts/boards/stores/boards_store.js | 123 +++++ .../javascripts/boards/stores/boards_store.js.es6 | 123 ----- .../javascripts/boards/stores/modal_store.js | 107 +++++ .../javascripts/boards/stores/modal_store.js.es6 | 107 ----- app/assets/javascripts/build_variables.js | 8 + app/assets/javascripts/build_variables.js.es6 | 8 - app/assets/javascripts/ci_lint_editor.js | 18 + app/assets/javascripts/ci_lint_editor.js.es6 | 18 - .../commit/pipelines/pipelines_bundle.js | 29 ++ .../commit/pipelines/pipelines_bundle.js.es6 | 29 -- .../commit/pipelines/pipelines_service.js | 44 ++ .../commit/pipelines/pipelines_service.js.es6 | 44 -- .../commit/pipelines/pipelines_store.js | 48 ++ .../commit/pipelines/pipelines_store.js.es6 | 48 -- .../commit/pipelines/pipelines_table.js | 104 +++++ .../commit/pipelines/pipelines_table.js.es6 | 104 ----- app/assets/javascripts/compare_autocomplete.js | 69 +++ app/assets/javascripts/compare_autocomplete.js.es6 | 69 --- app/assets/javascripts/copy_as_gfm.js | 361 +++++++++++++++ app/assets/javascripts/copy_as_gfm.js.es6 | 361 --------------- app/assets/javascripts/create_label.js | 132 ++++++ app/assets/javascripts/create_label.js.es6 | 132 ------ .../components/stage_code_component.js | 45 ++ .../components/stage_code_component.js.es6 | 45 -- .../components/stage_issue_component.js | 47 ++ .../components/stage_issue_component.js.es6 | 47 -- .../components/stage_plan_component.js | 56 +++ .../components/stage_plan_component.js.es6 | 56 --- .../components/stage_production_component.js | 47 ++ .../components/stage_production_component.js.es6 | 47 -- .../components/stage_review_component.js | 57 +++ .../components/stage_review_component.js.es6 | 57 --- .../components/stage_staging_component.js | 48 ++ .../components/stage_staging_component.js.es6 | 48 -- .../components/stage_test_component.js | 49 ++ .../components/stage_test_component.js.es6 | 49 -- .../components/total_time_component.js | 25 + .../components/total_time_component.js.es6 | 25 - .../cycle_analytics/cycle_analytics_bundle.js | 135 ++++++ .../cycle_analytics/cycle_analytics_bundle.js.es6 | 135 ------ .../cycle_analytics/cycle_analytics_service.js | 41 ++ .../cycle_analytics/cycle_analytics_service.js.es6 | 41 -- .../cycle_analytics/cycle_analytics_store.js | 104 +++++ .../cycle_analytics/cycle_analytics_store.js.es6 | 104 ----- .../cycle_analytics/default_event_objects.js | 98 ++++ .../cycle_analytics/default_event_objects.js.es6 | 98 ---- app/assets/javascripts/diff.js | 130 ++++++ app/assets/javascripts/diff.js.es6 | 130 ------ .../diff_notes/components/comment_resolve_btn.js | 60 +++ .../components/comment_resolve_btn.js.es6 | 60 --- .../diff_notes/components/jump_to_discussion.js | 194 ++++++++ .../components/jump_to_discussion.js.es6 | 194 -------- .../diff_notes/components/resolve_btn.js | 108 +++++ .../diff_notes/components/resolve_btn.js.es6 | 108 ----- .../diff_notes/components/resolve_count.js | 26 ++ .../diff_notes/components/resolve_count.js.es6 | 26 -- .../components/resolve_discussion_btn.js | 62 +++ .../components/resolve_discussion_btn.js.es6 | 62 --- .../javascripts/diff_notes/diff_notes_bundle.js | 57 +++ .../diff_notes/diff_notes_bundle.js.es6 | 57 --- .../javascripts/diff_notes/mixins/discussion.js | 37 ++ .../diff_notes/mixins/discussion.js.es6 | 37 -- .../javascripts/diff_notes/models/discussion.js | 96 ++++ .../diff_notes/models/discussion.js.es6 | 96 ---- app/assets/javascripts/diff_notes/models/note.js | 13 + .../javascripts/diff_notes/models/note.js.es6 | 13 - .../javascripts/diff_notes/services/resolve.js | 81 ++++ .../javascripts/diff_notes/services/resolve.js.es6 | 81 ---- .../javascripts/diff_notes/stores/comments.js | 57 +++ .../javascripts/diff_notes/stores/comments.js.es6 | 57 --- app/assets/javascripts/dispatcher.js | 407 ++++++++++++++++ app/assets/javascripts/dispatcher.js.es6 | 407 ---------------- app/assets/javascripts/due_date_select.js | 204 +++++++++ app/assets/javascripts/due_date_select.js.es6 | 204 --------- .../environments/components/environment.js | 186 ++++++++ .../environments/components/environment.js.es6 | 186 -------- .../environments/components/environment_actions.js | 41 ++ .../components/environment_actions.js.es6 | 41 -- .../components/environment_external_url.js | 19 + .../components/environment_external_url.js.es6 | 19 - .../environments/components/environment_item.js | 510 +++++++++++++++++++++ .../components/environment_item.js.es6 | 510 --------------------- .../components/environment_rollback.js | 30 ++ .../components/environment_rollback.js.es6 | 30 -- .../environments/components/environment_stop.js | 24 + .../components/environment_stop.js.es6 | 24 - .../components/environment_terminal_button.js | 26 ++ .../components/environment_terminal_button.js.es6 | 26 -- .../environments/components/environments_table.js | 56 +++ .../components/environments_table.js.es6 | 56 --- .../environments/environments_bundle.js | 13 + .../environments/environments_bundle.js.es6 | 13 - .../folder/environments_folder_bundle.js | 13 + .../folder/environments_folder_bundle.js.es6 | 13 - .../folder/environments_folder_view.js | 182 ++++++++ .../folder/environments_folder_view.js.es6 | 182 -------- .../environments/services/environments_service.js | 13 + .../services/environments_service.js.es6 | 13 - .../environments/stores/environments_store.js | 90 ++++ .../environments/stores/environments_store.js.es6 | 90 ---- app/assets/javascripts/extensions/array.js | 27 ++ app/assets/javascripts/extensions/array.js.es6 | 27 -- app/assets/javascripts/extensions/custom_event.js | 12 + .../javascripts/extensions/custom_event.js.es6 | 12 - app/assets/javascripts/extensions/element.js | 20 + app/assets/javascripts/extensions/element.js.es6 | 20 - app/assets/javascripts/extensions/object.js | 26 ++ app/assets/javascripts/extensions/object.js.es6 | 26 -- .../javascripts/filtered_search/dropdown_hint.js | 64 +++ .../filtered_search/dropdown_hint.js.es6 | 64 --- .../filtered_search/dropdown_non_user.js | 44 ++ .../filtered_search/dropdown_non_user.js.es6 | 44 -- .../javascripts/filtered_search/dropdown_user.js | 60 +++ .../filtered_search/dropdown_user.js.es6 | 60 --- .../javascripts/filtered_search/dropdown_utils.js | 126 +++++ .../filtered_search/dropdown_utils.js.es6 | 126 ----- .../filtered_search/filtered_search_dropdown.js | 123 +++++ .../filtered_search_dropdown.js.es6 | 123 ----- .../filtered_search_dropdown_manager.js | 210 +++++++++ .../filtered_search_dropdown_manager.js.es6 | 210 --------- .../filtered_search/filtered_search_manager.js | 231 ++++++++++ .../filtered_search/filtered_search_manager.js.es6 | 231 ---------- .../filtered_search/filtered_search_token_keys.js | 96 ++++ .../filtered_search_token_keys.js.es6 | 96 ---- .../filtered_search/filtered_search_tokenizer.js | 48 ++ .../filtered_search_tokenizer.js.es6 | 48 -- app/assets/javascripts/gfm_auto_complete.js | 383 ++++++++++++++++ app/assets/javascripts/gfm_auto_complete.js.es6 | 383 ---------------- app/assets/javascripts/gl_field_error.js | 164 +++++++ app/assets/javascripts/gl_field_error.js.es6 | 164 ------- app/assets/javascripts/gl_field_errors.js | 48 ++ app/assets/javascripts/gl_field_errors.js.es6 | 48 -- app/assets/javascripts/gl_form.js | 92 ++++ app/assets/javascripts/gl_form.js.es6 | 92 ---- app/assets/javascripts/group_label_subscription.js | 53 +++ .../javascripts/group_label_subscription.js.es6 | 53 --- app/assets/javascripts/issuable.js | 188 ++++++++ app/assets/javascripts/issuable.js.es6 | 188 -------- app/assets/javascripts/issuable/issuable_bundle.js | 1 + .../javascripts/issuable/issuable_bundle.js.es6 | 1 - .../time_tracking/components/collapsed_state.js | 42 ++ .../components/collapsed_state.js.es6 | 42 -- .../time_tracking/components/comparison_pane.js | 69 +++ .../components/comparison_pane.js.es6 | 69 --- .../time_tracking/components/estimate_only_pane.js | 13 + .../components/estimate_only_pane.js.es6 | 13 - .../time_tracking/components/help_state.js | 24 + .../time_tracking/components/help_state.js.es6 | 24 - .../time_tracking/components/no_tracking_pane.js | 11 + .../components/no_tracking_pane.js.es6 | 11 - .../time_tracking/components/spent_only_pane.js | 13 + .../components/spent_only_pane.js.es6 | 13 - .../time_tracking/components/time_tracker.js | 117 +++++ .../time_tracking/components/time_tracker.js.es6 | 117 ----- .../issuable/time_tracking/time_tracking_bundle.js | 65 +++ .../time_tracking/time_tracking_bundle.js.es6 | 65 --- app/assets/javascripts/issues_bulk_assignment.js | 163 +++++++ .../javascripts/issues_bulk_assignment.js.es6 | 163 ------- app/assets/javascripts/label_manager.js | 118 +++++ app/assets/javascripts/label_manager.js.es6 | 118 ----- .../javascripts/lib/utils/bootstrap_linked_tabs.js | 112 +++++ .../lib/utils/bootstrap_linked_tabs.js.es6 | 112 ----- app/assets/javascripts/lib/utils/common_utils.js | 342 ++++++++++++++ .../javascripts/lib/utils/common_utils.js.es6 | 342 -------------- .../javascripts/lib/utils/datetime_utility.js | 126 +++++ .../javascripts/lib/utils/datetime_utility.js.es6 | 126 ----- app/assets/javascripts/lib/utils/pretty_time.js | 65 +++ .../javascripts/lib/utils/pretty_time.js.es6 | 65 --- app/assets/javascripts/lib/utils/url_utility.js | 86 ++++ .../javascripts/lib/utils/url_utility.js.es6 | 86 ---- app/assets/javascripts/member_expiration_date.js | 52 +++ .../javascripts/member_expiration_date.js.es6 | 52 --- app/assets/javascripts/members.js | 81 ++++ app/assets/javascripts/members.js.es6 | 81 ---- .../merge_conflicts/components/diff_file_editor.js | 96 ++++ .../components/diff_file_editor.js.es6 | 96 ---- .../components/inline_conflict_lines.js | 13 + .../components/inline_conflict_lines.js.es6 | 13 - .../components/parallel_conflict_lines.js | 28 ++ .../components/parallel_conflict_lines.js.es6 | 28 -- .../merge_conflicts/merge_conflict_service.js | 31 ++ .../merge_conflicts/merge_conflict_service.js.es6 | 31 -- .../merge_conflicts/merge_conflict_store.js | 433 +++++++++++++++++ .../merge_conflicts/merge_conflict_store.js.es6 | 433 ----------------- .../merge_conflicts/merge_conflicts_bundle.js | 92 ++++ .../merge_conflicts/merge_conflicts_bundle.js.es6 | 92 ---- .../mixins/line_conflict_actions.js | 13 + .../mixins/line_conflict_actions.js.es6 | 13 - .../merge_conflicts/mixins/line_conflict_utils.js | 19 + .../mixins/line_conflict_utils.js.es6 | 19 - app/assets/javascripts/merge_request_tabs.js | 358 +++++++++++++++ app/assets/javascripts/merge_request_tabs.js.es6 | 358 --------------- app/assets/javascripts/merge_request_widget.js | 295 ++++++++++++ app/assets/javascripts/merge_request_widget.js.es6 | 295 ------------ .../javascripts/merge_request_widget/ci_bundle.js | 53 +++ .../merge_request_widget/ci_bundle.js.es6 | 53 --- .../javascripts/mini_pipeline_graph_dropdown.js | 95 ++++ .../mini_pipeline_graph_dropdown.js.es6 | 95 ---- app/assets/javascripts/pager.js | 73 +++ app/assets/javascripts/pager.js.es6 | 73 --- app/assets/javascripts/pipelines.js | 38 ++ app/assets/javascripts/pipelines.js.es6 | 38 -- app/assets/javascripts/profile/gl_crop.js | 173 +++++++ app/assets/javascripts/profile/gl_crop.js.es6 | 173 ------- app/assets/javascripts/profile/profile.js | 100 ++++ app/assets/javascripts/profile/profile.js.es6 | 100 ---- .../javascripts/project_label_subscription.js | 55 +++ .../javascripts/project_label_subscription.js.es6 | 55 --- app/assets/javascripts/project_variables.js | 43 ++ app/assets/javascripts/project_variables.js.es6 | 43 -- .../protected_branch_access_dropdown.js | 29 ++ .../protected_branch_access_dropdown.js.es6 | 29 -- .../protected_branches/protected_branch_create.js | 55 +++ .../protected_branch_create.js.es6 | 55 --- .../protected_branch_dropdown.js | 80 ++++ .../protected_branch_dropdown.js.es6 | 80 ---- .../protected_branches/protected_branch_edit.js | 69 +++ .../protected_branch_edit.js.es6 | 69 --- .../protected_branch_edit_list.js | 18 + .../protected_branch_edit_list.js.es6 | 18 - app/assets/javascripts/search_autocomplete.js | 432 +++++++++++++++++ app/assets/javascripts/search_autocomplete.js.es6 | 432 ----------------- app/assets/javascripts/shortcuts_blob.js | 29 ++ app/assets/javascripts/shortcuts_blob.js.es6 | 29 -- app/assets/javascripts/signin_tabs_memoizer.js | 49 ++ app/assets/javascripts/signin_tabs_memoizer.js.es6 | 49 -- app/assets/javascripts/smart_interval.js | 158 +++++++ app/assets/javascripts/smart_interval.js.es6 | 158 ------- app/assets/javascripts/snippets_list.js | 13 + app/assets/javascripts/snippets_list.js.es6 | 13 - app/assets/javascripts/subbable_resource.js | 51 +++ app/assets/javascripts/subbable_resource.js.es6 | 51 --- app/assets/javascripts/subscription.js | 50 ++ app/assets/javascripts/subscription.js.es6 | 50 -- .../templates/issuable_template_selector.js | 60 +++ .../templates/issuable_template_selector.js.es6 | 60 --- .../templates/issuable_template_selectors.js | 31 ++ .../templates/issuable_template_selectors.js.es6 | 31 -- app/assets/javascripts/terminal/terminal.js | 62 +++ app/assets/javascripts/terminal/terminal.js.es6 | 62 --- app/assets/javascripts/terminal/terminal_bundle.js | 7 + .../javascripts/terminal/terminal_bundle.js.es6 | 7 - app/assets/javascripts/todos.js | 146 ++++++ app/assets/javascripts/todos.js.es6 | 146 ------ app/assets/javascripts/u2f/authenticate.js | 118 +++++ app/assets/javascripts/u2f/authenticate.js.es6 | 118 ----- app/assets/javascripts/user.js | 34 ++ app/assets/javascripts/user.js.es6 | 34 -- app/assets/javascripts/user_tabs.js | 158 +++++++ app/assets/javascripts/user_tabs.js.es6 | 158 ------- app/assets/javascripts/username_validator.js | 135 ++++++ app/assets/javascripts/username_validator.js.es6 | 135 ------ app/assets/javascripts/version_check_image.js | 10 + app/assets/javascripts/version_check_image.js.es6 | 10 - app/assets/javascripts/visibility_select.js | 27 ++ app/assets/javascripts/visibility_select.js.es6 | 27 -- .../javascripts/vue_pipelines_index/index.js | 29 ++ .../javascripts/vue_pipelines_index/index.js.es6 | 29 -- .../vue_pipelines_index/pipeline_actions.js | 119 +++++ .../vue_pipelines_index/pipeline_actions.js.es6 | 119 ----- .../vue_pipelines_index/pipeline_url.js | 63 +++ .../vue_pipelines_index/pipeline_url.js.es6 | 63 --- .../javascripts/vue_pipelines_index/pipelines.js | 87 ++++ .../vue_pipelines_index/pipelines.js.es6 | 87 ---- .../javascripts/vue_pipelines_index/stage.js | 119 +++++ .../javascripts/vue_pipelines_index/stage.js.es6 | 119 ----- .../javascripts/vue_pipelines_index/status.js | 64 +++ .../javascripts/vue_pipelines_index/status.js.es6 | 64 --- .../javascripts/vue_pipelines_index/store.js | 31 ++ .../javascripts/vue_pipelines_index/store.js.es6 | 31 -- .../javascripts/vue_pipelines_index/time_ago.js | 78 ++++ .../vue_pipelines_index/time_ago.js.es6 | 78 ---- .../javascripts/vue_realtime_listener/index.js | 29 ++ .../javascripts/vue_realtime_listener/index.js.es6 | 29 -- .../javascripts/vue_shared/components/commit.js | 164 +++++++ .../vue_shared/components/commit.js.es6 | 164 ------- .../vue_shared/components/pipelines_table.js | 52 +++ .../vue_shared/components/pipelines_table.js.es6 | 52 --- .../vue_shared/components/pipelines_table_row.js | 199 ++++++++ .../components/pipelines_table_row.js.es6 | 199 -------- .../vue_shared/components/table_pagination.js | 147 ++++++ .../vue_shared/components/table_pagination.js.es6 | 147 ------ .../vue_shared/vue_resource_interceptor.js | 23 + .../vue_shared/vue_resource_interceptor.js.es6 | 23 - app/assets/javascripts/wikis.js | 69 +++ app/assets/javascripts/wikis.js.es6 | 69 --- spec/javascripts/abuse_reports_spec.js | 43 ++ spec/javascripts/abuse_reports_spec.js.es6 | 43 -- spec/javascripts/activities_spec.js | 62 +++ spec/javascripts/activities_spec.js.es6 | 62 --- spec/javascripts/boards/boards_store_spec.js | 158 +++++++ spec/javascripts/boards/boards_store_spec.js.es6 | 158 ------- spec/javascripts/boards/issue_card_spec.js | 191 ++++++++ spec/javascripts/boards/issue_card_spec.js.es6 | 191 -------- spec/javascripts/boards/issue_spec.js | 82 ++++ spec/javascripts/boards/issue_spec.js.es6 | 82 ---- spec/javascripts/boards/list_spec.js | 108 +++++ spec/javascripts/boards/list_spec.js.es6 | 108 ----- spec/javascripts/boards/mock_data.js | 63 +++ spec/javascripts/boards/mock_data.js.es6 | 63 --- spec/javascripts/boards/modal_store_spec.js | 132 ++++++ spec/javascripts/boards/modal_store_spec.js.es6 | 132 ------ spec/javascripts/bootstrap_linked_tabs_spec.js | 71 +++ spec/javascripts/bootstrap_linked_tabs_spec.js.es6 | 71 --- spec/javascripts/build_spec.js | 183 ++++++++ spec/javascripts/build_spec.js.es6 | 183 -------- spec/javascripts/commit/pipelines/mock_data.js | 92 ++++ spec/javascripts/commit/pipelines/mock_data.js.es6 | 92 ---- .../javascripts/commit/pipelines/pipelines_spec.js | 105 +++++ .../commit/pipelines/pipelines_spec.js.es6 | 105 ----- .../commit/pipelines/pipelines_store_spec.js | 33 ++ .../commit/pipelines/pipelines_store_spec.js.es6 | 33 -- spec/javascripts/commits_spec.js | 62 +++ spec/javascripts/commits_spec.js.es6 | 62 --- spec/javascripts/datetime_utility_spec.js | 65 +++ spec/javascripts/datetime_utility_spec.js.es6 | 65 --- spec/javascripts/diff_comments_store_spec.js | 124 +++++ spec/javascripts/diff_comments_store_spec.js.es6 | 124 ----- .../environments/environment_actions_spec.js | 36 ++ .../environments/environment_actions_spec.js.es6 | 36 -- .../environments/environment_external_url_spec.js | 21 + .../environment_external_url_spec.js.es6 | 21 - .../environments/environment_item_spec.js | 210 +++++++++ .../environments/environment_item_spec.js.es6 | 210 --------- .../environments/environment_rollback_spec.js | 47 ++ .../environments/environment_rollback_spec.js.es6 | 47 -- spec/javascripts/environments/environment_spec.js | 178 +++++++ .../environments/environment_spec.js.es6 | 178 ------- .../environments/environment_stop_spec.js | 28 ++ .../environments/environment_stop_spec.js.es6 | 28 -- .../environments/environment_table_spec.js | 30 ++ .../environments/environment_table_spec.js.es6 | 30 -- .../environments/environments_store_spec.js | 58 +++ .../environments/environments_store_spec.js.es6 | 58 --- .../folder/environments_folder_view_spec.js | 202 ++++++++ .../folder/environments_folder_view_spec.js.es6 | 202 -------- spec/javascripts/environments/mock_data.js | 92 ++++ spec/javascripts/environments/mock_data.js.es6 | 92 ---- spec/javascripts/extensions/array_spec.js | 45 ++ spec/javascripts/extensions/array_spec.js.es6 | 45 -- spec/javascripts/extensions/element_spec.js | 38 ++ spec/javascripts/extensions/element_spec.js.es6 | 38 -- spec/javascripts/extensions/object_spec.js | 25 + spec/javascripts/extensions/object_spec.js.es6 | 25 - .../filtered_search/dropdown_user_spec.js | 75 +++ .../filtered_search/dropdown_user_spec.js.es6 | 75 --- .../filtered_search/dropdown_utils_spec.js | 290 ++++++++++++ .../filtered_search/dropdown_utils_spec.js.es6 | 290 ------------ .../filtered_search_dropdown_manager_spec.js | 59 +++ .../filtered_search_dropdown_manager_spec.js.es6 | 59 --- .../filtered_search_manager_spec.js | 67 +++ .../filtered_search_manager_spec.js.es6 | 67 --- .../filtered_search_token_keys_spec.js | 110 +++++ .../filtered_search_token_keys_spec.js.es6 | 110 ----- .../filtered_search_tokenizer_spec.js | 127 +++++ .../filtered_search_tokenizer_spec.js.es6 | 127 ----- spec/javascripts/gfm_auto_complete_spec.js | 148 ++++++ spec/javascripts/gfm_auto_complete_spec.js.es6 | 148 ------ spec/javascripts/gl_dropdown_spec.js | 196 ++++++++ spec/javascripts/gl_dropdown_spec.js.es6 | 196 -------- spec/javascripts/gl_field_errors_spec.js | 110 +++++ spec/javascripts/gl_field_errors_spec.js.es6 | 110 ----- spec/javascripts/gl_form_spec.js | 123 +++++ spec/javascripts/gl_form_spec.js.es6 | 123 ----- spec/javascripts/helpers/class_spec_helper.js | 11 + spec/javascripts/helpers/class_spec_helper.js.es6 | 11 - spec/javascripts/helpers/class_spec_helper_spec.js | 36 ++ .../helpers/class_spec_helper_spec.js.es6 | 36 -- spec/javascripts/issuable_spec.js | 80 ++++ spec/javascripts/issuable_spec.js.es6 | 80 ---- spec/javascripts/issuable_time_tracker_spec.js | 202 ++++++++ spec/javascripts/issuable_time_tracker_spec.js.es6 | 202 -------- spec/javascripts/labels_issue_sidebar_spec.js | 90 ++++ spec/javascripts/labels_issue_sidebar_spec.js.es6 | 90 ---- spec/javascripts/lib/utils/common_utils_spec.js | 167 +++++++ .../javascripts/lib/utils/common_utils_spec.js.es6 | 167 ------- spec/javascripts/lib/utils/text_utility_spec.js | 110 +++++ .../javascripts/lib/utils/text_utility_spec.js.es6 | 110 ----- .../mini_pipeline_graph_dropdown_spec.js | 51 +++ .../mini_pipeline_graph_dropdown_spec.js.es6 | 51 --- spec/javascripts/pipelines_spec.js | 30 ++ spec/javascripts/pipelines_spec.js.es6 | 30 -- spec/javascripts/pretty_time_spec.js | 134 ++++++ spec/javascripts/pretty_time_spec.js.es6 | 134 ------ spec/javascripts/signin_tabs_memoizer_spec.js | 53 +++ spec/javascripts/signin_tabs_memoizer_spec.js.es6 | 53 --- spec/javascripts/smart_interval_spec.js | 179 ++++++++ spec/javascripts/smart_interval_spec.js.es6 | 179 -------- spec/javascripts/subbable_resource_spec.js | 63 +++ spec/javascripts/subbable_resource_spec.js.es6 | 63 --- spec/javascripts/user_callout_spec.js | 57 +++ spec/javascripts/user_callout_spec.js.es6 | 57 --- spec/javascripts/version_check_image_spec.js | 33 ++ spec/javascripts/version_check_image_spec.js.es6 | 33 -- spec/javascripts/visibility_select_spec.js | 100 ++++ spec/javascripts/visibility_select_spec.js.es6 | 100 ---- .../vue_shared/components/commit_spec.js | 131 ++++++ .../vue_shared/components/commit_spec.js.es6 | 131 ------ .../components/pipelines_table_row_spec.js | 87 ++++ .../components/pipelines_table_row_spec.js.es6 | 87 ---- .../vue_shared/components/pipelines_table_spec.js | 64 +++ .../components/pipelines_table_spec.js.es6 | 64 --- .../vue_shared/components/table_pagination_spec.js | 158 +++++++ .../components/table_pagination_spec.js.es6 | 158 ------- 476 files changed, 21286 insertions(+), 21286 deletions(-) create mode 100644 app/assets/javascripts/abuse_reports.js delete mode 100644 app/assets/javascripts/abuse_reports.js.es6 create mode 100644 app/assets/javascripts/activities.js delete mode 100644 app/assets/javascripts/activities.js.es6 create mode 100644 app/assets/javascripts/blob/blob_ci_yaml.js delete mode 100644 app/assets/javascripts/blob/blob_ci_yaml.js.es6 create mode 100644 app/assets/javascripts/blob/blob_dockerfile_selector.js delete mode 100644 app/assets/javascripts/blob/blob_dockerfile_selector.js.es6 create mode 100644 app/assets/javascripts/blob/blob_dockerfile_selectors.js delete mode 100644 app/assets/javascripts/blob/blob_dockerfile_selectors.js.es6 create mode 100644 app/assets/javascripts/blob/blob_license_selectors.js delete mode 100644 app/assets/javascripts/blob/blob_license_selectors.js.es6 create mode 100644 app/assets/javascripts/blob/template_selector.js delete mode 100644 app/assets/javascripts/blob/template_selector.js.es6 create mode 100644 app/assets/javascripts/boards/boards_bundle.js delete mode 100644 app/assets/javascripts/boards/boards_bundle.js.es6 create mode 100644 app/assets/javascripts/boards/components/board.js delete mode 100644 app/assets/javascripts/boards/components/board.js.es6 create mode 100644 app/assets/javascripts/boards/components/board_blank_state.js delete mode 100644 app/assets/javascripts/boards/components/board_blank_state.js.es6 create mode 100644 app/assets/javascripts/boards/components/board_delete.js delete mode 100644 app/assets/javascripts/boards/components/board_delete.js.es6 create mode 100644 app/assets/javascripts/boards/components/board_list.js delete mode 100644 app/assets/javascripts/boards/components/board_list.js.es6 create mode 100644 app/assets/javascripts/boards/components/board_sidebar.js delete mode 100644 app/assets/javascripts/boards/components/board_sidebar.js.es6 create mode 100644 app/assets/javascripts/boards/components/issue_card_inner.js delete mode 100644 app/assets/javascripts/boards/components/issue_card_inner.js.es6 create mode 100644 app/assets/javascripts/boards/components/modal/empty_state.js delete mode 100644 app/assets/javascripts/boards/components/modal/empty_state.js.es6 create mode 100644 app/assets/javascripts/boards/components/modal/filters.js delete mode 100644 app/assets/javascripts/boards/components/modal/filters.js.es6 create mode 100644 app/assets/javascripts/boards/components/modal/filters/label.js delete mode 100644 app/assets/javascripts/boards/components/modal/filters/label.js.es6 create mode 100644 app/assets/javascripts/boards/components/modal/filters/milestone.js delete mode 100644 app/assets/javascripts/boards/components/modal/filters/milestone.js.es6 create mode 100644 app/assets/javascripts/boards/components/modal/filters/user.js delete mode 100644 app/assets/javascripts/boards/components/modal/filters/user.js.es6 create mode 100644 app/assets/javascripts/boards/components/modal/footer.js delete mode 100644 app/assets/javascripts/boards/components/modal/footer.js.es6 create mode 100644 app/assets/javascripts/boards/components/modal/header.js delete mode 100644 app/assets/javascripts/boards/components/modal/header.js.es6 create mode 100644 app/assets/javascripts/boards/components/modal/index.js delete mode 100644 app/assets/javascripts/boards/components/modal/index.js.es6 create mode 100644 app/assets/javascripts/boards/components/modal/list.js delete mode 100644 app/assets/javascripts/boards/components/modal/list.js.es6 create mode 100644 app/assets/javascripts/boards/components/modal/lists_dropdown.js delete mode 100644 app/assets/javascripts/boards/components/modal/lists_dropdown.js.es6 create mode 100644 app/assets/javascripts/boards/components/modal/tabs.js delete mode 100644 app/assets/javascripts/boards/components/modal/tabs.js.es6 create mode 100644 app/assets/javascripts/boards/components/new_list_dropdown.js delete mode 100644 app/assets/javascripts/boards/components/new_list_dropdown.js.es6 create mode 100644 app/assets/javascripts/boards/components/sidebar/remove_issue.js delete mode 100644 app/assets/javascripts/boards/components/sidebar/remove_issue.js.es6 create mode 100644 app/assets/javascripts/boards/filters/due_date_filters.js delete mode 100644 app/assets/javascripts/boards/filters/due_date_filters.js.es6 create mode 100644 app/assets/javascripts/boards/mixins/modal_mixins.js delete mode 100644 app/assets/javascripts/boards/mixins/modal_mixins.js.es6 create mode 100644 app/assets/javascripts/boards/mixins/sortable_default_options.js delete mode 100644 app/assets/javascripts/boards/mixins/sortable_default_options.js.es6 create mode 100644 app/assets/javascripts/boards/models/issue.js delete mode 100644 app/assets/javascripts/boards/models/issue.js.es6 create mode 100644 app/assets/javascripts/boards/models/label.js delete mode 100644 app/assets/javascripts/boards/models/label.js.es6 create mode 100644 app/assets/javascripts/boards/models/list.js delete mode 100644 app/assets/javascripts/boards/models/list.js.es6 create mode 100644 app/assets/javascripts/boards/models/milestone.js delete mode 100644 app/assets/javascripts/boards/models/milestone.js.es6 create mode 100644 app/assets/javascripts/boards/models/user.js delete mode 100644 app/assets/javascripts/boards/models/user.js.es6 create mode 100644 app/assets/javascripts/boards/services/board_service.js delete mode 100644 app/assets/javascripts/boards/services/board_service.js.es6 create mode 100644 app/assets/javascripts/boards/stores/boards_store.js delete mode 100644 app/assets/javascripts/boards/stores/boards_store.js.es6 create mode 100644 app/assets/javascripts/boards/stores/modal_store.js delete mode 100644 app/assets/javascripts/boards/stores/modal_store.js.es6 create mode 100644 app/assets/javascripts/build_variables.js delete mode 100644 app/assets/javascripts/build_variables.js.es6 create mode 100644 app/assets/javascripts/ci_lint_editor.js delete mode 100644 app/assets/javascripts/ci_lint_editor.js.es6 create mode 100644 app/assets/javascripts/commit/pipelines/pipelines_bundle.js delete mode 100644 app/assets/javascripts/commit/pipelines/pipelines_bundle.js.es6 create mode 100644 app/assets/javascripts/commit/pipelines/pipelines_service.js delete mode 100644 app/assets/javascripts/commit/pipelines/pipelines_service.js.es6 create mode 100644 app/assets/javascripts/commit/pipelines/pipelines_store.js delete mode 100644 app/assets/javascripts/commit/pipelines/pipelines_store.js.es6 create mode 100644 app/assets/javascripts/commit/pipelines/pipelines_table.js delete mode 100644 app/assets/javascripts/commit/pipelines/pipelines_table.js.es6 create mode 100644 app/assets/javascripts/compare_autocomplete.js delete mode 100644 app/assets/javascripts/compare_autocomplete.js.es6 create mode 100644 app/assets/javascripts/copy_as_gfm.js delete mode 100644 app/assets/javascripts/copy_as_gfm.js.es6 create mode 100644 app/assets/javascripts/create_label.js delete mode 100644 app/assets/javascripts/create_label.js.es6 create mode 100644 app/assets/javascripts/cycle_analytics/components/stage_code_component.js delete mode 100644 app/assets/javascripts/cycle_analytics/components/stage_code_component.js.es6 create mode 100644 app/assets/javascripts/cycle_analytics/components/stage_issue_component.js delete mode 100644 app/assets/javascripts/cycle_analytics/components/stage_issue_component.js.es6 create mode 100644 app/assets/javascripts/cycle_analytics/components/stage_plan_component.js delete mode 100644 app/assets/javascripts/cycle_analytics/components/stage_plan_component.js.es6 create mode 100644 app/assets/javascripts/cycle_analytics/components/stage_production_component.js delete mode 100644 app/assets/javascripts/cycle_analytics/components/stage_production_component.js.es6 create mode 100644 app/assets/javascripts/cycle_analytics/components/stage_review_component.js delete mode 100644 app/assets/javascripts/cycle_analytics/components/stage_review_component.js.es6 create mode 100644 app/assets/javascripts/cycle_analytics/components/stage_staging_component.js delete mode 100644 app/assets/javascripts/cycle_analytics/components/stage_staging_component.js.es6 create mode 100644 app/assets/javascripts/cycle_analytics/components/stage_test_component.js delete mode 100644 app/assets/javascripts/cycle_analytics/components/stage_test_component.js.es6 create mode 100644 app/assets/javascripts/cycle_analytics/components/total_time_component.js delete mode 100644 app/assets/javascripts/cycle_analytics/components/total_time_component.js.es6 create mode 100644 app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js delete mode 100644 app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js.es6 create mode 100644 app/assets/javascripts/cycle_analytics/cycle_analytics_service.js delete mode 100644 app/assets/javascripts/cycle_analytics/cycle_analytics_service.js.es6 create mode 100644 app/assets/javascripts/cycle_analytics/cycle_analytics_store.js delete mode 100644 app/assets/javascripts/cycle_analytics/cycle_analytics_store.js.es6 create mode 100644 app/assets/javascripts/cycle_analytics/default_event_objects.js delete mode 100644 app/assets/javascripts/cycle_analytics/default_event_objects.js.es6 create mode 100644 app/assets/javascripts/diff.js delete mode 100644 app/assets/javascripts/diff.js.es6 create mode 100644 app/assets/javascripts/diff_notes/components/comment_resolve_btn.js delete mode 100644 app/assets/javascripts/diff_notes/components/comment_resolve_btn.js.es6 create mode 100644 app/assets/javascripts/diff_notes/components/jump_to_discussion.js delete mode 100644 app/assets/javascripts/diff_notes/components/jump_to_discussion.js.es6 create mode 100644 app/assets/javascripts/diff_notes/components/resolve_btn.js delete mode 100644 app/assets/javascripts/diff_notes/components/resolve_btn.js.es6 create mode 100644 app/assets/javascripts/diff_notes/components/resolve_count.js delete mode 100644 app/assets/javascripts/diff_notes/components/resolve_count.js.es6 create mode 100644 app/assets/javascripts/diff_notes/components/resolve_discussion_btn.js delete mode 100644 app/assets/javascripts/diff_notes/components/resolve_discussion_btn.js.es6 create mode 100644 app/assets/javascripts/diff_notes/diff_notes_bundle.js delete mode 100644 app/assets/javascripts/diff_notes/diff_notes_bundle.js.es6 create mode 100644 app/assets/javascripts/diff_notes/mixins/discussion.js delete mode 100644 app/assets/javascripts/diff_notes/mixins/discussion.js.es6 create mode 100644 app/assets/javascripts/diff_notes/models/discussion.js delete mode 100644 app/assets/javascripts/diff_notes/models/discussion.js.es6 create mode 100644 app/assets/javascripts/diff_notes/models/note.js delete mode 100644 app/assets/javascripts/diff_notes/models/note.js.es6 create mode 100644 app/assets/javascripts/diff_notes/services/resolve.js delete mode 100644 app/assets/javascripts/diff_notes/services/resolve.js.es6 create mode 100644 app/assets/javascripts/diff_notes/stores/comments.js delete mode 100644 app/assets/javascripts/diff_notes/stores/comments.js.es6 create mode 100644 app/assets/javascripts/dispatcher.js delete mode 100644 app/assets/javascripts/dispatcher.js.es6 create mode 100644 app/assets/javascripts/due_date_select.js delete mode 100644 app/assets/javascripts/due_date_select.js.es6 create mode 100644 app/assets/javascripts/environments/components/environment.js delete mode 100644 app/assets/javascripts/environments/components/environment.js.es6 create mode 100644 app/assets/javascripts/environments/components/environment_actions.js delete mode 100644 app/assets/javascripts/environments/components/environment_actions.js.es6 create mode 100644 app/assets/javascripts/environments/components/environment_external_url.js delete mode 100644 app/assets/javascripts/environments/components/environment_external_url.js.es6 create mode 100644 app/assets/javascripts/environments/components/environment_item.js delete mode 100644 app/assets/javascripts/environments/components/environment_item.js.es6 create mode 100644 app/assets/javascripts/environments/components/environment_rollback.js delete mode 100644 app/assets/javascripts/environments/components/environment_rollback.js.es6 create mode 100644 app/assets/javascripts/environments/components/environment_stop.js delete mode 100644 app/assets/javascripts/environments/components/environment_stop.js.es6 create mode 100644 app/assets/javascripts/environments/components/environment_terminal_button.js delete mode 100644 app/assets/javascripts/environments/components/environment_terminal_button.js.es6 create mode 100644 app/assets/javascripts/environments/components/environments_table.js delete mode 100644 app/assets/javascripts/environments/components/environments_table.js.es6 create mode 100644 app/assets/javascripts/environments/environments_bundle.js delete mode 100644 app/assets/javascripts/environments/environments_bundle.js.es6 create mode 100644 app/assets/javascripts/environments/folder/environments_folder_bundle.js delete mode 100644 app/assets/javascripts/environments/folder/environments_folder_bundle.js.es6 create mode 100644 app/assets/javascripts/environments/folder/environments_folder_view.js delete mode 100644 app/assets/javascripts/environments/folder/environments_folder_view.js.es6 create mode 100644 app/assets/javascripts/environments/services/environments_service.js delete mode 100644 app/assets/javascripts/environments/services/environments_service.js.es6 create mode 100644 app/assets/javascripts/environments/stores/environments_store.js delete mode 100644 app/assets/javascripts/environments/stores/environments_store.js.es6 create mode 100644 app/assets/javascripts/extensions/array.js delete mode 100644 app/assets/javascripts/extensions/array.js.es6 create mode 100644 app/assets/javascripts/extensions/custom_event.js delete mode 100644 app/assets/javascripts/extensions/custom_event.js.es6 create mode 100644 app/assets/javascripts/extensions/element.js delete mode 100644 app/assets/javascripts/extensions/element.js.es6 create mode 100644 app/assets/javascripts/extensions/object.js delete mode 100644 app/assets/javascripts/extensions/object.js.es6 create mode 100644 app/assets/javascripts/filtered_search/dropdown_hint.js delete mode 100644 app/assets/javascripts/filtered_search/dropdown_hint.js.es6 create mode 100644 app/assets/javascripts/filtered_search/dropdown_non_user.js delete mode 100644 app/assets/javascripts/filtered_search/dropdown_non_user.js.es6 create mode 100644 app/assets/javascripts/filtered_search/dropdown_user.js delete mode 100644 app/assets/javascripts/filtered_search/dropdown_user.js.es6 create mode 100644 app/assets/javascripts/filtered_search/dropdown_utils.js delete mode 100644 app/assets/javascripts/filtered_search/dropdown_utils.js.es6 create mode 100644 app/assets/javascripts/filtered_search/filtered_search_dropdown.js delete mode 100644 app/assets/javascripts/filtered_search/filtered_search_dropdown.js.es6 create mode 100644 app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js delete mode 100644 app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js.es6 create mode 100644 app/assets/javascripts/filtered_search/filtered_search_manager.js delete mode 100644 app/assets/javascripts/filtered_search/filtered_search_manager.js.es6 create mode 100644 app/assets/javascripts/filtered_search/filtered_search_token_keys.js delete mode 100644 app/assets/javascripts/filtered_search/filtered_search_token_keys.js.es6 create mode 100644 app/assets/javascripts/filtered_search/filtered_search_tokenizer.js delete mode 100644 app/assets/javascripts/filtered_search/filtered_search_tokenizer.js.es6 create mode 100644 app/assets/javascripts/gfm_auto_complete.js delete mode 100644 app/assets/javascripts/gfm_auto_complete.js.es6 create mode 100644 app/assets/javascripts/gl_field_error.js delete mode 100644 app/assets/javascripts/gl_field_error.js.es6 create mode 100644 app/assets/javascripts/gl_field_errors.js delete mode 100644 app/assets/javascripts/gl_field_errors.js.es6 create mode 100644 app/assets/javascripts/gl_form.js delete mode 100644 app/assets/javascripts/gl_form.js.es6 create mode 100644 app/assets/javascripts/group_label_subscription.js delete mode 100644 app/assets/javascripts/group_label_subscription.js.es6 create mode 100644 app/assets/javascripts/issuable.js delete mode 100644 app/assets/javascripts/issuable.js.es6 create mode 100644 app/assets/javascripts/issuable/issuable_bundle.js delete mode 100644 app/assets/javascripts/issuable/issuable_bundle.js.es6 create mode 100644 app/assets/javascripts/issuable/time_tracking/components/collapsed_state.js delete mode 100644 app/assets/javascripts/issuable/time_tracking/components/collapsed_state.js.es6 create mode 100644 app/assets/javascripts/issuable/time_tracking/components/comparison_pane.js delete mode 100644 app/assets/javascripts/issuable/time_tracking/components/comparison_pane.js.es6 create mode 100644 app/assets/javascripts/issuable/time_tracking/components/estimate_only_pane.js delete mode 100644 app/assets/javascripts/issuable/time_tracking/components/estimate_only_pane.js.es6 create mode 100644 app/assets/javascripts/issuable/time_tracking/components/help_state.js delete mode 100644 app/assets/javascripts/issuable/time_tracking/components/help_state.js.es6 create mode 100644 app/assets/javascripts/issuable/time_tracking/components/no_tracking_pane.js delete mode 100644 app/assets/javascripts/issuable/time_tracking/components/no_tracking_pane.js.es6 create mode 100644 app/assets/javascripts/issuable/time_tracking/components/spent_only_pane.js delete mode 100644 app/assets/javascripts/issuable/time_tracking/components/spent_only_pane.js.es6 create mode 100644 app/assets/javascripts/issuable/time_tracking/components/time_tracker.js delete mode 100644 app/assets/javascripts/issuable/time_tracking/components/time_tracker.js.es6 create mode 100644 app/assets/javascripts/issuable/time_tracking/time_tracking_bundle.js delete mode 100644 app/assets/javascripts/issuable/time_tracking/time_tracking_bundle.js.es6 create mode 100644 app/assets/javascripts/issues_bulk_assignment.js delete mode 100644 app/assets/javascripts/issues_bulk_assignment.js.es6 create mode 100644 app/assets/javascripts/label_manager.js delete mode 100644 app/assets/javascripts/label_manager.js.es6 create mode 100644 app/assets/javascripts/lib/utils/bootstrap_linked_tabs.js delete mode 100644 app/assets/javascripts/lib/utils/bootstrap_linked_tabs.js.es6 create mode 100644 app/assets/javascripts/lib/utils/common_utils.js delete mode 100644 app/assets/javascripts/lib/utils/common_utils.js.es6 create mode 100644 app/assets/javascripts/lib/utils/datetime_utility.js delete mode 100644 app/assets/javascripts/lib/utils/datetime_utility.js.es6 create mode 100644 app/assets/javascripts/lib/utils/pretty_time.js delete mode 100644 app/assets/javascripts/lib/utils/pretty_time.js.es6 create mode 100644 app/assets/javascripts/lib/utils/url_utility.js delete mode 100644 app/assets/javascripts/lib/utils/url_utility.js.es6 create mode 100644 app/assets/javascripts/member_expiration_date.js delete mode 100644 app/assets/javascripts/member_expiration_date.js.es6 create mode 100644 app/assets/javascripts/members.js delete mode 100644 app/assets/javascripts/members.js.es6 create mode 100644 app/assets/javascripts/merge_conflicts/components/diff_file_editor.js delete mode 100644 app/assets/javascripts/merge_conflicts/components/diff_file_editor.js.es6 create mode 100644 app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.js delete mode 100644 app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.js.es6 create mode 100644 app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.js delete mode 100644 app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.js.es6 create mode 100644 app/assets/javascripts/merge_conflicts/merge_conflict_service.js delete mode 100644 app/assets/javascripts/merge_conflicts/merge_conflict_service.js.es6 create mode 100644 app/assets/javascripts/merge_conflicts/merge_conflict_store.js delete mode 100644 app/assets/javascripts/merge_conflicts/merge_conflict_store.js.es6 create mode 100644 app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js delete mode 100644 app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js.es6 create mode 100644 app/assets/javascripts/merge_conflicts/mixins/line_conflict_actions.js delete mode 100644 app/assets/javascripts/merge_conflicts/mixins/line_conflict_actions.js.es6 create mode 100644 app/assets/javascripts/merge_conflicts/mixins/line_conflict_utils.js delete mode 100644 app/assets/javascripts/merge_conflicts/mixins/line_conflict_utils.js.es6 create mode 100644 app/assets/javascripts/merge_request_tabs.js delete mode 100644 app/assets/javascripts/merge_request_tabs.js.es6 create mode 100644 app/assets/javascripts/merge_request_widget.js delete mode 100644 app/assets/javascripts/merge_request_widget.js.es6 create mode 100644 app/assets/javascripts/merge_request_widget/ci_bundle.js delete mode 100644 app/assets/javascripts/merge_request_widget/ci_bundle.js.es6 create mode 100644 app/assets/javascripts/mini_pipeline_graph_dropdown.js delete mode 100644 app/assets/javascripts/mini_pipeline_graph_dropdown.js.es6 create mode 100644 app/assets/javascripts/pager.js delete mode 100644 app/assets/javascripts/pager.js.es6 create mode 100644 app/assets/javascripts/pipelines.js delete mode 100644 app/assets/javascripts/pipelines.js.es6 create mode 100644 app/assets/javascripts/profile/gl_crop.js delete mode 100644 app/assets/javascripts/profile/gl_crop.js.es6 create mode 100644 app/assets/javascripts/profile/profile.js delete mode 100644 app/assets/javascripts/profile/profile.js.es6 create mode 100644 app/assets/javascripts/project_label_subscription.js delete mode 100644 app/assets/javascripts/project_label_subscription.js.es6 create mode 100644 app/assets/javascripts/project_variables.js delete mode 100644 app/assets/javascripts/project_variables.js.es6 create mode 100644 app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js delete mode 100644 app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js.es6 create mode 100644 app/assets/javascripts/protected_branches/protected_branch_create.js delete mode 100644 app/assets/javascripts/protected_branches/protected_branch_create.js.es6 create mode 100644 app/assets/javascripts/protected_branches/protected_branch_dropdown.js delete mode 100644 app/assets/javascripts/protected_branches/protected_branch_dropdown.js.es6 create mode 100644 app/assets/javascripts/protected_branches/protected_branch_edit.js delete mode 100644 app/assets/javascripts/protected_branches/protected_branch_edit.js.es6 create mode 100644 app/assets/javascripts/protected_branches/protected_branch_edit_list.js delete mode 100644 app/assets/javascripts/protected_branches/protected_branch_edit_list.js.es6 create mode 100644 app/assets/javascripts/search_autocomplete.js delete mode 100644 app/assets/javascripts/search_autocomplete.js.es6 create mode 100644 app/assets/javascripts/shortcuts_blob.js delete mode 100644 app/assets/javascripts/shortcuts_blob.js.es6 create mode 100644 app/assets/javascripts/signin_tabs_memoizer.js delete mode 100644 app/assets/javascripts/signin_tabs_memoizer.js.es6 create mode 100644 app/assets/javascripts/smart_interval.js delete mode 100644 app/assets/javascripts/smart_interval.js.es6 create mode 100644 app/assets/javascripts/snippets_list.js delete mode 100644 app/assets/javascripts/snippets_list.js.es6 create mode 100644 app/assets/javascripts/subbable_resource.js delete mode 100644 app/assets/javascripts/subbable_resource.js.es6 create mode 100644 app/assets/javascripts/subscription.js delete mode 100644 app/assets/javascripts/subscription.js.es6 create mode 100644 app/assets/javascripts/templates/issuable_template_selector.js delete mode 100644 app/assets/javascripts/templates/issuable_template_selector.js.es6 create mode 100644 app/assets/javascripts/templates/issuable_template_selectors.js delete mode 100644 app/assets/javascripts/templates/issuable_template_selectors.js.es6 create mode 100644 app/assets/javascripts/terminal/terminal.js delete mode 100644 app/assets/javascripts/terminal/terminal.js.es6 create mode 100644 app/assets/javascripts/terminal/terminal_bundle.js delete mode 100644 app/assets/javascripts/terminal/terminal_bundle.js.es6 create mode 100644 app/assets/javascripts/todos.js delete mode 100644 app/assets/javascripts/todos.js.es6 create mode 100644 app/assets/javascripts/u2f/authenticate.js delete mode 100644 app/assets/javascripts/u2f/authenticate.js.es6 create mode 100644 app/assets/javascripts/user.js delete mode 100644 app/assets/javascripts/user.js.es6 create mode 100644 app/assets/javascripts/user_tabs.js delete mode 100644 app/assets/javascripts/user_tabs.js.es6 create mode 100644 app/assets/javascripts/username_validator.js delete mode 100644 app/assets/javascripts/username_validator.js.es6 create mode 100644 app/assets/javascripts/version_check_image.js delete mode 100644 app/assets/javascripts/version_check_image.js.es6 create mode 100644 app/assets/javascripts/visibility_select.js delete mode 100644 app/assets/javascripts/visibility_select.js.es6 create mode 100644 app/assets/javascripts/vue_pipelines_index/index.js delete mode 100644 app/assets/javascripts/vue_pipelines_index/index.js.es6 create mode 100644 app/assets/javascripts/vue_pipelines_index/pipeline_actions.js delete mode 100644 app/assets/javascripts/vue_pipelines_index/pipeline_actions.js.es6 create mode 100644 app/assets/javascripts/vue_pipelines_index/pipeline_url.js delete mode 100644 app/assets/javascripts/vue_pipelines_index/pipeline_url.js.es6 create mode 100644 app/assets/javascripts/vue_pipelines_index/pipelines.js delete mode 100644 app/assets/javascripts/vue_pipelines_index/pipelines.js.es6 create mode 100644 app/assets/javascripts/vue_pipelines_index/stage.js delete mode 100644 app/assets/javascripts/vue_pipelines_index/stage.js.es6 create mode 100644 app/assets/javascripts/vue_pipelines_index/status.js delete mode 100644 app/assets/javascripts/vue_pipelines_index/status.js.es6 create mode 100644 app/assets/javascripts/vue_pipelines_index/store.js delete mode 100644 app/assets/javascripts/vue_pipelines_index/store.js.es6 create mode 100644 app/assets/javascripts/vue_pipelines_index/time_ago.js delete mode 100644 app/assets/javascripts/vue_pipelines_index/time_ago.js.es6 create mode 100644 app/assets/javascripts/vue_realtime_listener/index.js delete mode 100644 app/assets/javascripts/vue_realtime_listener/index.js.es6 create mode 100644 app/assets/javascripts/vue_shared/components/commit.js delete mode 100644 app/assets/javascripts/vue_shared/components/commit.js.es6 create mode 100644 app/assets/javascripts/vue_shared/components/pipelines_table.js delete mode 100644 app/assets/javascripts/vue_shared/components/pipelines_table.js.es6 create mode 100644 app/assets/javascripts/vue_shared/components/pipelines_table_row.js delete mode 100644 app/assets/javascripts/vue_shared/components/pipelines_table_row.js.es6 create mode 100644 app/assets/javascripts/vue_shared/components/table_pagination.js delete mode 100644 app/assets/javascripts/vue_shared/components/table_pagination.js.es6 create mode 100644 app/assets/javascripts/vue_shared/vue_resource_interceptor.js delete mode 100644 app/assets/javascripts/vue_shared/vue_resource_interceptor.js.es6 create mode 100644 app/assets/javascripts/wikis.js delete mode 100644 app/assets/javascripts/wikis.js.es6 create mode 100644 spec/javascripts/abuse_reports_spec.js delete mode 100644 spec/javascripts/abuse_reports_spec.js.es6 create mode 100644 spec/javascripts/activities_spec.js delete mode 100644 spec/javascripts/activities_spec.js.es6 create mode 100644 spec/javascripts/boards/boards_store_spec.js delete mode 100644 spec/javascripts/boards/boards_store_spec.js.es6 create mode 100644 spec/javascripts/boards/issue_card_spec.js delete mode 100644 spec/javascripts/boards/issue_card_spec.js.es6 create mode 100644 spec/javascripts/boards/issue_spec.js delete mode 100644 spec/javascripts/boards/issue_spec.js.es6 create mode 100644 spec/javascripts/boards/list_spec.js delete mode 100644 spec/javascripts/boards/list_spec.js.es6 create mode 100644 spec/javascripts/boards/mock_data.js delete mode 100644 spec/javascripts/boards/mock_data.js.es6 create mode 100644 spec/javascripts/boards/modal_store_spec.js delete mode 100644 spec/javascripts/boards/modal_store_spec.js.es6 create mode 100644 spec/javascripts/bootstrap_linked_tabs_spec.js delete mode 100644 spec/javascripts/bootstrap_linked_tabs_spec.js.es6 create mode 100644 spec/javascripts/build_spec.js delete mode 100644 spec/javascripts/build_spec.js.es6 create mode 100644 spec/javascripts/commit/pipelines/mock_data.js delete mode 100644 spec/javascripts/commit/pipelines/mock_data.js.es6 create mode 100644 spec/javascripts/commit/pipelines/pipelines_spec.js delete mode 100644 spec/javascripts/commit/pipelines/pipelines_spec.js.es6 create mode 100644 spec/javascripts/commit/pipelines/pipelines_store_spec.js delete mode 100644 spec/javascripts/commit/pipelines/pipelines_store_spec.js.es6 create mode 100644 spec/javascripts/commits_spec.js delete mode 100644 spec/javascripts/commits_spec.js.es6 create mode 100644 spec/javascripts/datetime_utility_spec.js delete mode 100644 spec/javascripts/datetime_utility_spec.js.es6 create mode 100644 spec/javascripts/diff_comments_store_spec.js delete mode 100644 spec/javascripts/diff_comments_store_spec.js.es6 create mode 100644 spec/javascripts/environments/environment_actions_spec.js delete mode 100644 spec/javascripts/environments/environment_actions_spec.js.es6 create mode 100644 spec/javascripts/environments/environment_external_url_spec.js delete mode 100644 spec/javascripts/environments/environment_external_url_spec.js.es6 create mode 100644 spec/javascripts/environments/environment_item_spec.js delete mode 100644 spec/javascripts/environments/environment_item_spec.js.es6 create mode 100644 spec/javascripts/environments/environment_rollback_spec.js delete mode 100644 spec/javascripts/environments/environment_rollback_spec.js.es6 create mode 100644 spec/javascripts/environments/environment_spec.js delete mode 100644 spec/javascripts/environments/environment_spec.js.es6 create mode 100644 spec/javascripts/environments/environment_stop_spec.js delete mode 100644 spec/javascripts/environments/environment_stop_spec.js.es6 create mode 100644 spec/javascripts/environments/environment_table_spec.js delete mode 100644 spec/javascripts/environments/environment_table_spec.js.es6 create mode 100644 spec/javascripts/environments/environments_store_spec.js delete mode 100644 spec/javascripts/environments/environments_store_spec.js.es6 create mode 100644 spec/javascripts/environments/folder/environments_folder_view_spec.js delete mode 100644 spec/javascripts/environments/folder/environments_folder_view_spec.js.es6 create mode 100644 spec/javascripts/environments/mock_data.js delete mode 100644 spec/javascripts/environments/mock_data.js.es6 create mode 100644 spec/javascripts/extensions/array_spec.js delete mode 100644 spec/javascripts/extensions/array_spec.js.es6 create mode 100644 spec/javascripts/extensions/element_spec.js delete mode 100644 spec/javascripts/extensions/element_spec.js.es6 create mode 100644 spec/javascripts/extensions/object_spec.js delete mode 100644 spec/javascripts/extensions/object_spec.js.es6 create mode 100644 spec/javascripts/filtered_search/dropdown_user_spec.js delete mode 100644 spec/javascripts/filtered_search/dropdown_user_spec.js.es6 create mode 100644 spec/javascripts/filtered_search/dropdown_utils_spec.js delete mode 100644 spec/javascripts/filtered_search/dropdown_utils_spec.js.es6 create mode 100644 spec/javascripts/filtered_search/filtered_search_dropdown_manager_spec.js delete mode 100644 spec/javascripts/filtered_search/filtered_search_dropdown_manager_spec.js.es6 create mode 100644 spec/javascripts/filtered_search/filtered_search_manager_spec.js delete mode 100644 spec/javascripts/filtered_search/filtered_search_manager_spec.js.es6 create mode 100644 spec/javascripts/filtered_search/filtered_search_token_keys_spec.js delete mode 100644 spec/javascripts/filtered_search/filtered_search_token_keys_spec.js.es6 create mode 100644 spec/javascripts/filtered_search/filtered_search_tokenizer_spec.js delete mode 100644 spec/javascripts/filtered_search/filtered_search_tokenizer_spec.js.es6 create mode 100644 spec/javascripts/gfm_auto_complete_spec.js delete mode 100644 spec/javascripts/gfm_auto_complete_spec.js.es6 create mode 100644 spec/javascripts/gl_dropdown_spec.js delete mode 100644 spec/javascripts/gl_dropdown_spec.js.es6 create mode 100644 spec/javascripts/gl_field_errors_spec.js delete mode 100644 spec/javascripts/gl_field_errors_spec.js.es6 create mode 100644 spec/javascripts/gl_form_spec.js delete mode 100644 spec/javascripts/gl_form_spec.js.es6 create mode 100644 spec/javascripts/helpers/class_spec_helper.js delete mode 100644 spec/javascripts/helpers/class_spec_helper.js.es6 create mode 100644 spec/javascripts/helpers/class_spec_helper_spec.js delete mode 100644 spec/javascripts/helpers/class_spec_helper_spec.js.es6 create mode 100644 spec/javascripts/issuable_spec.js delete mode 100644 spec/javascripts/issuable_spec.js.es6 create mode 100644 spec/javascripts/issuable_time_tracker_spec.js delete mode 100644 spec/javascripts/issuable_time_tracker_spec.js.es6 create mode 100644 spec/javascripts/labels_issue_sidebar_spec.js delete mode 100644 spec/javascripts/labels_issue_sidebar_spec.js.es6 create mode 100644 spec/javascripts/lib/utils/common_utils_spec.js delete mode 100644 spec/javascripts/lib/utils/common_utils_spec.js.es6 create mode 100644 spec/javascripts/lib/utils/text_utility_spec.js delete mode 100644 spec/javascripts/lib/utils/text_utility_spec.js.es6 create mode 100644 spec/javascripts/mini_pipeline_graph_dropdown_spec.js delete mode 100644 spec/javascripts/mini_pipeline_graph_dropdown_spec.js.es6 create mode 100644 spec/javascripts/pipelines_spec.js delete mode 100644 spec/javascripts/pipelines_spec.js.es6 create mode 100644 spec/javascripts/pretty_time_spec.js delete mode 100644 spec/javascripts/pretty_time_spec.js.es6 create mode 100644 spec/javascripts/signin_tabs_memoizer_spec.js delete mode 100644 spec/javascripts/signin_tabs_memoizer_spec.js.es6 create mode 100644 spec/javascripts/smart_interval_spec.js delete mode 100644 spec/javascripts/smart_interval_spec.js.es6 create mode 100644 spec/javascripts/subbable_resource_spec.js delete mode 100644 spec/javascripts/subbable_resource_spec.js.es6 create mode 100644 spec/javascripts/user_callout_spec.js delete mode 100644 spec/javascripts/user_callout_spec.js.es6 create mode 100644 spec/javascripts/version_check_image_spec.js delete mode 100644 spec/javascripts/version_check_image_spec.js.es6 create mode 100644 spec/javascripts/visibility_select_spec.js delete mode 100644 spec/javascripts/visibility_select_spec.js.es6 create mode 100644 spec/javascripts/vue_shared/components/commit_spec.js delete mode 100644 spec/javascripts/vue_shared/components/commit_spec.js.es6 create mode 100644 spec/javascripts/vue_shared/components/pipelines_table_row_spec.js delete mode 100644 spec/javascripts/vue_shared/components/pipelines_table_row_spec.js.es6 create mode 100644 spec/javascripts/vue_shared/components/pipelines_table_spec.js delete mode 100644 spec/javascripts/vue_shared/components/pipelines_table_spec.js.es6 create mode 100644 spec/javascripts/vue_shared/components/table_pagination_spec.js delete mode 100644 spec/javascripts/vue_shared/components/table_pagination_spec.js.es6 diff --git a/app/assets/javascripts/abuse_reports.js b/app/assets/javascripts/abuse_reports.js new file mode 100644 index 00000000000..8a260aae1b1 --- /dev/null +++ b/app/assets/javascripts/abuse_reports.js @@ -0,0 +1,40 @@ +/* eslint-disable no-param-reassign */ + +((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); + } + + 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))}...`); + } + } + } + + global.AbuseReports = AbuseReports; +})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/abuse_reports.js.es6 b/app/assets/javascripts/abuse_reports.js.es6 deleted file mode 100644 index 8a260aae1b1..00000000000 --- a/app/assets/javascripts/abuse_reports.js.es6 +++ /dev/null @@ -1,40 +0,0 @@ -/* eslint-disable no-param-reassign */ - -((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); - } - - 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))}...`); - } - } - } - - global.AbuseReports = AbuseReports; -})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/activities.js b/app/assets/javascripts/activities.js new file mode 100644 index 00000000000..648cb4d5d85 --- /dev/null +++ b/app/assets/javascripts/activities.js @@ -0,0 +1,37 @@ +/* eslint-disable no-param-reassign, class-methods-use-this */ +/* 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(); + }); + } + + updateTooltips() { + gl.utils.localTimeAgo($('.js-timeago', '.content_list')); + } + + reloadActivities() { + $('.content_list').html(''); + Pager.init(20, true, false, this.updateTooltips); + } + + toggleFilter(sender) { + const $sender = $(sender); + const filter = $sender.attr('id').split('_')[0]; + + $('.event-filter .active').removeClass('active'); + Cookies.set('event_filter', filter); + + $sender.closest('li').toggleClass('active'); + } + } + + global.Activities = Activities; +})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/activities.js.es6 b/app/assets/javascripts/activities.js.es6 deleted file mode 100644 index 648cb4d5d85..00000000000 --- a/app/assets/javascripts/activities.js.es6 +++ /dev/null @@ -1,37 +0,0 @@ -/* eslint-disable no-param-reassign, class-methods-use-this */ -/* 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(); - }); - } - - updateTooltips() { - gl.utils.localTimeAgo($('.js-timeago', '.content_list')); - } - - reloadActivities() { - $('.content_list').html(''); - Pager.init(20, true, false, this.updateTooltips); - } - - toggleFilter(sender) { - const $sender = $(sender); - const filter = $sender.attr('id').split('_')[0]; - - $('.event-filter .active').removeClass('active'); - Cookies.set('event_filter', filter); - - $sender.closest('li').toggleClass('active'); - } - } - - global.Activities = Activities; -})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/blob/blob_ci_yaml.js b/app/assets/javascripts/blob/blob_ci_yaml.js new file mode 100644 index 00000000000..ec1c018424d --- /dev/null +++ b/app/assets/javascripts/blob/blob_ci_yaml.js @@ -0,0 +1,42 @@ +/* eslint-disable no-param-reassign, comma-dangle */ +/* global Api */ + +require('./template_selector'); + +((global) => { + class BlobCiYamlSelector extends gl.TemplateSelector { + requestFile(query) { + return Api.gitlabCiYml(query.name, this.requestFileSuccess.bind(this)); + } + + requestFileSuccess(file) { + return super.requestFileSuccess(file); + } + } + + global.BlobCiYamlSelector = BlobCiYamlSelector; + + class BlobCiYamlSelectors { + constructor({ editor, $dropdowns } = {}) { + this.editor = editor; + this.$dropdowns = $dropdowns || $('.js-gitlab-ci-yml-selector'); + this.initSelectors(); + } + + initSelectors() { + const editor = this.editor; + this.$dropdowns.each((i, dropdown) => { + const $dropdown = $(dropdown); + return new BlobCiYamlSelector({ + editor, + pattern: /(.gitlab-ci.yml)/, + data: $dropdown.data('data'), + wrapper: $dropdown.closest('.js-gitlab-ci-yml-selector-wrap'), + dropdown: $dropdown + }); + }); + } + } + + global.BlobCiYamlSelectors = BlobCiYamlSelectors; +})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/blob/blob_ci_yaml.js.es6 b/app/assets/javascripts/blob/blob_ci_yaml.js.es6 deleted file mode 100644 index ec1c018424d..00000000000 --- a/app/assets/javascripts/blob/blob_ci_yaml.js.es6 +++ /dev/null @@ -1,42 +0,0 @@ -/* eslint-disable no-param-reassign, comma-dangle */ -/* global Api */ - -require('./template_selector'); - -((global) => { - class BlobCiYamlSelector extends gl.TemplateSelector { - requestFile(query) { - return Api.gitlabCiYml(query.name, this.requestFileSuccess.bind(this)); - } - - requestFileSuccess(file) { - return super.requestFileSuccess(file); - } - } - - global.BlobCiYamlSelector = BlobCiYamlSelector; - - class BlobCiYamlSelectors { - constructor({ editor, $dropdowns } = {}) { - this.editor = editor; - this.$dropdowns = $dropdowns || $('.js-gitlab-ci-yml-selector'); - this.initSelectors(); - } - - initSelectors() { - const editor = this.editor; - this.$dropdowns.each((i, dropdown) => { - const $dropdown = $(dropdown); - return new BlobCiYamlSelector({ - editor, - pattern: /(.gitlab-ci.yml)/, - data: $dropdown.data('data'), - wrapper: $dropdown.closest('.js-gitlab-ci-yml-selector-wrap'), - dropdown: $dropdown - }); - }); - } - } - - global.BlobCiYamlSelectors = BlobCiYamlSelectors; -})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/blob/blob_dockerfile_selector.js b/app/assets/javascripts/blob/blob_dockerfile_selector.js new file mode 100644 index 00000000000..d4f60cc6ecd --- /dev/null +++ b/app/assets/javascripts/blob/blob_dockerfile_selector.js @@ -0,0 +1,19 @@ +/* global Api */ + +require('./template_selector'); + +(() => { + const global = window.gl || (window.gl = {}); + + class BlobDockerfileSelector extends gl.TemplateSelector { + requestFile(query) { + return Api.dockerfileYml(query.name, this.requestFileSuccess.bind(this)); + } + + requestFileSuccess(file) { + return super.requestFileSuccess(file); + } + } + + global.BlobDockerfileSelector = BlobDockerfileSelector; +})(); diff --git a/app/assets/javascripts/blob/blob_dockerfile_selector.js.es6 b/app/assets/javascripts/blob/blob_dockerfile_selector.js.es6 deleted file mode 100644 index d4f60cc6ecd..00000000000 --- a/app/assets/javascripts/blob/blob_dockerfile_selector.js.es6 +++ /dev/null @@ -1,19 +0,0 @@ -/* global Api */ - -require('./template_selector'); - -(() => { - const global = window.gl || (window.gl = {}); - - class BlobDockerfileSelector extends gl.TemplateSelector { - requestFile(query) { - return Api.dockerfileYml(query.name, this.requestFileSuccess.bind(this)); - } - - requestFileSuccess(file) { - return super.requestFileSuccess(file); - } - } - - global.BlobDockerfileSelector = BlobDockerfileSelector; -})(); diff --git a/app/assets/javascripts/blob/blob_dockerfile_selectors.js b/app/assets/javascripts/blob/blob_dockerfile_selectors.js new file mode 100644 index 00000000000..9cee79fa5d5 --- /dev/null +++ b/app/assets/javascripts/blob/blob_dockerfile_selectors.js @@ -0,0 +1,27 @@ +(() => { + const global = window.gl || (window.gl = {}); + + class BlobDockerfileSelectors { + constructor({ editor, $dropdowns } = {}) { + this.editor = editor; + this.$dropdowns = $dropdowns || $('.js-dockerfile-selector'); + this.initSelectors(); + } + + initSelectors() { + const editor = this.editor; + this.$dropdowns.each((i, dropdown) => { + const $dropdown = $(dropdown); + return new gl.BlobDockerfileSelector({ + editor, + pattern: /(Dockerfile)/, + data: $dropdown.data('data'), + wrapper: $dropdown.closest('.js-dockerfile-selector-wrap'), + dropdown: $dropdown, + }); + }); + } + } + + global.BlobDockerfileSelectors = BlobDockerfileSelectors; +})(); diff --git a/app/assets/javascripts/blob/blob_dockerfile_selectors.js.es6 b/app/assets/javascripts/blob/blob_dockerfile_selectors.js.es6 deleted file mode 100644 index 9cee79fa5d5..00000000000 --- a/app/assets/javascripts/blob/blob_dockerfile_selectors.js.es6 +++ /dev/null @@ -1,27 +0,0 @@ -(() => { - const global = window.gl || (window.gl = {}); - - class BlobDockerfileSelectors { - constructor({ editor, $dropdowns } = {}) { - this.editor = editor; - this.$dropdowns = $dropdowns || $('.js-dockerfile-selector'); - this.initSelectors(); - } - - initSelectors() { - const editor = this.editor; - this.$dropdowns.each((i, dropdown) => { - const $dropdown = $(dropdown); - return new gl.BlobDockerfileSelector({ - editor, - pattern: /(Dockerfile)/, - data: $dropdown.data('data'), - wrapper: $dropdown.closest('.js-dockerfile-selector-wrap'), - dropdown: $dropdown, - }); - }); - } - } - - global.BlobDockerfileSelectors = BlobDockerfileSelectors; -})(); diff --git a/app/assets/javascripts/blob/blob_license_selectors.js b/app/assets/javascripts/blob/blob_license_selectors.js new file mode 100644 index 00000000000..c5067b0feae --- /dev/null +++ b/app/assets/javascripts/blob/blob_license_selectors.js @@ -0,0 +1,23 @@ +/* eslint-disable no-unused-vars, no-param-reassign */ +/* global BlobLicenseSelector */ + +((global) => { + class BlobLicenseSelectors { + constructor({ $dropdowns, editor }) { + this.$dropdowns = $('.js-license-selector'); + this.editor = editor; + this.$dropdowns.each((i, dropdown) => { + const $dropdown = $(dropdown); + return new BlobLicenseSelector({ + editor, + pattern: /^(.+\/)?(licen[sc]e|copying)($|\.)/i, + data: $dropdown.data('data'), + wrapper: $dropdown.closest('.js-license-selector-wrap'), + dropdown: $dropdown, + }); + }); + } + } + + global.BlobLicenseSelectors = BlobLicenseSelectors; +})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/blob/blob_license_selectors.js.es6 b/app/assets/javascripts/blob/blob_license_selectors.js.es6 deleted file mode 100644 index c5067b0feae..00000000000 --- a/app/assets/javascripts/blob/blob_license_selectors.js.es6 +++ /dev/null @@ -1,23 +0,0 @@ -/* eslint-disable no-unused-vars, no-param-reassign */ -/* global BlobLicenseSelector */ - -((global) => { - class BlobLicenseSelectors { - constructor({ $dropdowns, editor }) { - this.$dropdowns = $('.js-license-selector'); - this.editor = editor; - this.$dropdowns.each((i, dropdown) => { - const $dropdown = $(dropdown); - return new BlobLicenseSelector({ - editor, - pattern: /^(.+\/)?(licen[sc]e|copying)($|\.)/i, - data: $dropdown.data('data'), - wrapper: $dropdown.closest('.js-license-selector-wrap'), - dropdown: $dropdown, - }); - }); - } - } - - global.BlobLicenseSelectors = BlobLicenseSelectors; -})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/blob/template_selector.js b/app/assets/javascripts/blob/template_selector.js new file mode 100644 index 00000000000..7e03ec3b391 --- /dev/null +++ b/app/assets/javascripts/blob/template_selector.js @@ -0,0 +1,101 @@ +/* eslint-disable comma-dangle, object-shorthand, func-names, space-before-function-paren, arrow-parens, no-unused-vars, class-methods-use-this, no-var, consistent-return, no-param-reassign, max-len */ + +((global) => { + class TemplateSelector { + constructor({ dropdown, data, pattern, wrapper, editor, fileEndpoint, $input } = {}) { + this.onClick = this.onClick.bind(this); + this.dropdown = dropdown; + this.data = data; + this.pattern = pattern; + this.wrapper = wrapper; + this.editor = editor; + this.fileEndpoint = fileEndpoint; + this.$input = $input || $('#file_name'); + this.dropdownIcon = $('.fa-chevron-down', this.dropdown); + this.buildDropdown(); + this.bindEvents(); + this.onFilenameUpdate(); + + this.autosizeUpdateEvent = document.createEvent('Event'); + this.autosizeUpdateEvent.initEvent('autosize:update', true, false); + } + + buildDropdown() { + return this.dropdown.glDropdown({ + data: this.data, + filterable: true, + selectable: true, + toggleLabel: this.toggleLabel, + search: { + fields: ['name'] + }, + clicked: this.onClick, + text: function(item) { + return item.name; + } + }); + } + + bindEvents() { + return this.$input.on('keyup blur', (e) => this.onFilenameUpdate()); + } + + toggleLabel(item) { + return item.name; + } + + onFilenameUpdate() { + var filenameMatches; + if (!this.$input.length) { + return; + } + filenameMatches = this.pattern.test(this.$input.val().trim()); + if (!filenameMatches) { + this.wrapper.addClass('hidden'); + return; + } + return this.wrapper.removeClass('hidden'); + } + + onClick(item, el, e) { + e.preventDefault(); + return this.requestFile(item); + } + + requestFile(item) { + // This `requestFile` method is an abstract method that should + // be added by all subclasses. + } + + // To be implemented on the extending class + // e.g. + // Api.gitignoreText item.name, @requestFileSuccess.bind(@) + requestFileSuccess(file, { skipFocus } = {}) { + if (!file) return; + + const oldValue = this.editor.getValue(); + const newValue = file.content; + + this.editor.setValue(newValue, 1); + if (!skipFocus) this.editor.focus(); + + if (this.editor instanceof jQuery) { + this.editor.get(0).dispatchEvent(this.autosizeUpdateEvent); + } + } + + startLoadingSpinner() { + this.dropdownIcon + .addClass('fa-spinner fa-spin') + .removeClass('fa-chevron-down'); + } + + stopLoadingSpinner() { + this.dropdownIcon + .addClass('fa-chevron-down') + .removeClass('fa-spinner fa-spin'); + } + } + + global.TemplateSelector = TemplateSelector; +})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/blob/template_selector.js.es6 b/app/assets/javascripts/blob/template_selector.js.es6 deleted file mode 100644 index 7e03ec3b391..00000000000 --- a/app/assets/javascripts/blob/template_selector.js.es6 +++ /dev/null @@ -1,101 +0,0 @@ -/* eslint-disable comma-dangle, object-shorthand, func-names, space-before-function-paren, arrow-parens, no-unused-vars, class-methods-use-this, no-var, consistent-return, no-param-reassign, max-len */ - -((global) => { - class TemplateSelector { - constructor({ dropdown, data, pattern, wrapper, editor, fileEndpoint, $input } = {}) { - this.onClick = this.onClick.bind(this); - this.dropdown = dropdown; - this.data = data; - this.pattern = pattern; - this.wrapper = wrapper; - this.editor = editor; - this.fileEndpoint = fileEndpoint; - this.$input = $input || $('#file_name'); - this.dropdownIcon = $('.fa-chevron-down', this.dropdown); - this.buildDropdown(); - this.bindEvents(); - this.onFilenameUpdate(); - - this.autosizeUpdateEvent = document.createEvent('Event'); - this.autosizeUpdateEvent.initEvent('autosize:update', true, false); - } - - buildDropdown() { - return this.dropdown.glDropdown({ - data: this.data, - filterable: true, - selectable: true, - toggleLabel: this.toggleLabel, - search: { - fields: ['name'] - }, - clicked: this.onClick, - text: function(item) { - return item.name; - } - }); - } - - bindEvents() { - return this.$input.on('keyup blur', (e) => this.onFilenameUpdate()); - } - - toggleLabel(item) { - return item.name; - } - - onFilenameUpdate() { - var filenameMatches; - if (!this.$input.length) { - return; - } - filenameMatches = this.pattern.test(this.$input.val().trim()); - if (!filenameMatches) { - this.wrapper.addClass('hidden'); - return; - } - return this.wrapper.removeClass('hidden'); - } - - onClick(item, el, e) { - e.preventDefault(); - return this.requestFile(item); - } - - requestFile(item) { - // This `requestFile` method is an abstract method that should - // be added by all subclasses. - } - - // To be implemented on the extending class - // e.g. - // Api.gitignoreText item.name, @requestFileSuccess.bind(@) - requestFileSuccess(file, { skipFocus } = {}) { - if (!file) return; - - const oldValue = this.editor.getValue(); - const newValue = file.content; - - this.editor.setValue(newValue, 1); - if (!skipFocus) this.editor.focus(); - - if (this.editor instanceof jQuery) { - this.editor.get(0).dispatchEvent(this.autosizeUpdateEvent); - } - } - - startLoadingSpinner() { - this.dropdownIcon - .addClass('fa-spinner fa-spin') - .removeClass('fa-chevron-down'); - } - - stopLoadingSpinner() { - this.dropdownIcon - .addClass('fa-chevron-down') - .removeClass('fa-spinner fa-spin'); - } - } - - global.TemplateSelector = TemplateSelector; -})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/boards/boards_bundle.js b/app/assets/javascripts/boards/boards_bundle.js new file mode 100644 index 00000000000..55d13be6e5f --- /dev/null +++ b/app/assets/javascripts/boards/boards_bundle.js @@ -0,0 +1,151 @@ +/* eslint-disable one-var, quote-props, comma-dangle, space-before-function-paren */ +/* global Vue */ +/* global BoardService */ + +window.Vue = require('vue'); +window.Vue.use(require('vue-resource')); +require('./models/issue'); +require('./models/label'); +require('./models/list'); +require('./models/milestone'); +require('./models/user'); +require('./stores/boards_store'); +require('./stores/modal_store'); +require('./services/board_service'); +require('./mixins/modal_mixins'); +require('./mixins/sortable_default_options'); +require('./filters/due_date_filters'); +require('./components/board'); +require('./components/board_sidebar'); +require('./components/new_list_dropdown'); +require('./components/modal/index'); +require('../vue_shared/vue_resource_interceptor'); + +$(() => { + const $boardApp = document.getElementById('board-app'); + const Store = gl.issueBoards.BoardsStore; + const ModalStore = gl.issueBoards.ModalStore; + + window.gl = window.gl || {}; + + if (gl.IssueBoardsApp) { + gl.IssueBoardsApp.$destroy(true); + } + + Store.create(); + + gl.IssueBoardsApp = new Vue({ + el: $boardApp, + components: { + 'board': gl.issueBoards.Board, + 'board-sidebar': gl.issueBoards.BoardSidebar, + 'board-add-issues-modal': gl.issueBoards.IssuesModal, + }, + data: { + state: Store.state, + loading: true, + endpoint: $boardApp.dataset.endpoint, + boardId: $boardApp.dataset.boardId, + disabled: $boardApp.dataset.disabled === 'true', + issueLinkBase: $boardApp.dataset.issueLinkBase, + rootPath: $boardApp.dataset.rootPath, + bulkUpdatePath: $boardApp.dataset.bulkUpdatePath, + detailIssue: Store.detail + }, + computed: { + detailIssueVisible () { + return Object.keys(this.detailIssue.issue).length; + }, + }, + created () { + gl.boardService = new BoardService(this.endpoint, this.bulkUpdatePath, this.boardId); + }, + mounted () { + Store.disabled = this.disabled; + gl.boardService.all() + .then((resp) => { + resp.json().forEach((board) => { + const list = Store.addList(board); + + if (list.type === 'done') { + list.position = Infinity; + } + }); + + this.state.lists = _.sortBy(this.state.lists, 'position'); + + Store.addBlankState(); + this.loading = false; + }); + } + }); + + gl.IssueBoardsSearch = new Vue({ + el: document.getElementById('js-boards-search'), + data: { + filters: Store.state.filters + }, + mounted () { + gl.issueBoards.newListDropdownInit(); + } + }); + + gl.IssueBoardsModalAddBtn = new Vue({ + mixins: [gl.issueBoards.ModalMixins], + el: document.getElementById('js-add-issues-btn'), + data: { + modal: ModalStore.store, + store: Store.state, + }, + watch: { + disabled() { + this.updateTooltip(); + }, + }, + computed: { + disabled() { + return !this.store.lists.filter(list => list.type !== 'blank' && list.type !== 'done').length; + }, + tooltipTitle() { + if (this.disabled) { + return 'Please add a list to your board first'; + } + + return ''; + }, + }, + methods: { + updateTooltip() { + const $tooltip = $(this.$el); + + this.$nextTick(() => { + if (this.disabled) { + $tooltip.tooltip(); + } else { + $tooltip.tooltip('destroy'); + } + }); + }, + openModal() { + if (!this.disabled) { + this.toggleModal(true); + } + }, + }, + mounted() { + this.updateTooltip(); + }, + template: ` + + `, + }); +}); diff --git a/app/assets/javascripts/boards/boards_bundle.js.es6 b/app/assets/javascripts/boards/boards_bundle.js.es6 deleted file mode 100644 index 55d13be6e5f..00000000000 --- a/app/assets/javascripts/boards/boards_bundle.js.es6 +++ /dev/null @@ -1,151 +0,0 @@ -/* eslint-disable one-var, quote-props, comma-dangle, space-before-function-paren */ -/* global Vue */ -/* global BoardService */ - -window.Vue = require('vue'); -window.Vue.use(require('vue-resource')); -require('./models/issue'); -require('./models/label'); -require('./models/list'); -require('./models/milestone'); -require('./models/user'); -require('./stores/boards_store'); -require('./stores/modal_store'); -require('./services/board_service'); -require('./mixins/modal_mixins'); -require('./mixins/sortable_default_options'); -require('./filters/due_date_filters'); -require('./components/board'); -require('./components/board_sidebar'); -require('./components/new_list_dropdown'); -require('./components/modal/index'); -require('../vue_shared/vue_resource_interceptor'); - -$(() => { - const $boardApp = document.getElementById('board-app'); - const Store = gl.issueBoards.BoardsStore; - const ModalStore = gl.issueBoards.ModalStore; - - window.gl = window.gl || {}; - - if (gl.IssueBoardsApp) { - gl.IssueBoardsApp.$destroy(true); - } - - Store.create(); - - gl.IssueBoardsApp = new Vue({ - el: $boardApp, - components: { - 'board': gl.issueBoards.Board, - 'board-sidebar': gl.issueBoards.BoardSidebar, - 'board-add-issues-modal': gl.issueBoards.IssuesModal, - }, - data: { - state: Store.state, - loading: true, - endpoint: $boardApp.dataset.endpoint, - boardId: $boardApp.dataset.boardId, - disabled: $boardApp.dataset.disabled === 'true', - issueLinkBase: $boardApp.dataset.issueLinkBase, - rootPath: $boardApp.dataset.rootPath, - bulkUpdatePath: $boardApp.dataset.bulkUpdatePath, - detailIssue: Store.detail - }, - computed: { - detailIssueVisible () { - return Object.keys(this.detailIssue.issue).length; - }, - }, - created () { - gl.boardService = new BoardService(this.endpoint, this.bulkUpdatePath, this.boardId); - }, - mounted () { - Store.disabled = this.disabled; - gl.boardService.all() - .then((resp) => { - resp.json().forEach((board) => { - const list = Store.addList(board); - - if (list.type === 'done') { - list.position = Infinity; - } - }); - - this.state.lists = _.sortBy(this.state.lists, 'position'); - - Store.addBlankState(); - this.loading = false; - }); - } - }); - - gl.IssueBoardsSearch = new Vue({ - el: document.getElementById('js-boards-search'), - data: { - filters: Store.state.filters - }, - mounted () { - gl.issueBoards.newListDropdownInit(); - } - }); - - gl.IssueBoardsModalAddBtn = new Vue({ - mixins: [gl.issueBoards.ModalMixins], - el: document.getElementById('js-add-issues-btn'), - data: { - modal: ModalStore.store, - store: Store.state, - }, - watch: { - disabled() { - this.updateTooltip(); - }, - }, - computed: { - disabled() { - return !this.store.lists.filter(list => list.type !== 'blank' && list.type !== 'done').length; - }, - tooltipTitle() { - if (this.disabled) { - return 'Please add a list to your board first'; - } - - return ''; - }, - }, - methods: { - updateTooltip() { - const $tooltip = $(this.$el); - - this.$nextTick(() => { - if (this.disabled) { - $tooltip.tooltip(); - } else { - $tooltip.tooltip('destroy'); - } - }); - }, - openModal() { - if (!this.disabled) { - this.toggleModal(true); - } - }, - }, - mounted() { - this.updateTooltip(); - }, - template: ` - - `, - }); -}); diff --git a/app/assets/javascripts/boards/components/board.js b/app/assets/javascripts/boards/components/board.js new file mode 100644 index 00000000000..18324de18b3 --- /dev/null +++ b/app/assets/javascripts/boards/components/board.js @@ -0,0 +1,105 @@ +/* eslint-disable comma-dangle, space-before-function-paren, one-var */ +/* global Vue */ +/* global Sortable */ + +require('./board_blank_state'); +require('./board_delete'); +require('./board_list'); + +(() => { + const Store = gl.issueBoards.BoardsStore; + + window.gl = window.gl || {}; + window.gl.issueBoards = window.gl.issueBoards || {}; + + gl.issueBoards.Board = Vue.extend({ + template: '#js-board-template', + components: { + 'board-list': gl.issueBoards.BoardList, + 'board-delete': gl.issueBoards.BoardDelete, + 'board-blank-state': gl.issueBoards.BoardBlankState + }, + props: { + list: Object, + disabled: Boolean, + issueLinkBase: String, + rootPath: String, + }, + data () { + return { + detailIssue: Store.detail, + filters: Store.state.filters, + }; + }, + watch: { + filters: { + handler () { + this.list.page = 1; + this.list.getIssues(true); + }, + deep: true + }, + detailIssue: { + handler () { + if (!Object.keys(this.detailIssue.issue).length) return; + + const issue = this.list.findIssue(this.detailIssue.issue.id); + + if (issue) { + const offsetLeft = this.$el.offsetLeft; + const boardsList = document.querySelectorAll('.boards-list')[0]; + const left = boardsList.scrollLeft - offsetLeft; + let right = (offsetLeft + this.$el.offsetWidth); + + if (window.innerWidth > 768 && boardsList.classList.contains('is-compact')) { + // -290 here because width of boardsList is animating so therefore + // getting the width here is incorrect + // 290 is the width of the sidebar + right -= (boardsList.offsetWidth - 290); + } else { + right -= boardsList.offsetWidth; + } + + if (right - boardsList.scrollLeft > 0) { + $(boardsList).animate({ + scrollLeft: right + }, this.sortableOptions.animation); + } else if (left > 0) { + $(boardsList).animate({ + scrollLeft: offsetLeft + }, this.sortableOptions.animation); + } + } + }, + deep: true + } + }, + methods: { + showNewIssueForm() { + this.$refs['board-list'].showIssueForm = !this.$refs['board-list'].showIssueForm; + } + }, + mounted () { + this.sortableOptions = gl.issueBoards.getBoardSortableDefaultOptions({ + disabled: this.disabled, + group: 'boards', + draggable: '.is-draggable', + handle: '.js-board-handle', + onEnd: (e) => { + gl.issueBoards.onEnd(); + + if (e.newIndex !== undefined && e.oldIndex !== e.newIndex) { + const order = this.sortable.toArray(); + const list = Store.findList('id', parseInt(e.item.dataset.id, 10)); + + this.$nextTick(() => { + Store.moveList(list, order); + }); + } + } + }); + + this.sortable = Sortable.create(this.$el.parentNode, this.sortableOptions); + }, + }); +})(); diff --git a/app/assets/javascripts/boards/components/board.js.es6 b/app/assets/javascripts/boards/components/board.js.es6 deleted file mode 100644 index 18324de18b3..00000000000 --- a/app/assets/javascripts/boards/components/board.js.es6 +++ /dev/null @@ -1,105 +0,0 @@ -/* eslint-disable comma-dangle, space-before-function-paren, one-var */ -/* global Vue */ -/* global Sortable */ - -require('./board_blank_state'); -require('./board_delete'); -require('./board_list'); - -(() => { - const Store = gl.issueBoards.BoardsStore; - - window.gl = window.gl || {}; - window.gl.issueBoards = window.gl.issueBoards || {}; - - gl.issueBoards.Board = Vue.extend({ - template: '#js-board-template', - components: { - 'board-list': gl.issueBoards.BoardList, - 'board-delete': gl.issueBoards.BoardDelete, - 'board-blank-state': gl.issueBoards.BoardBlankState - }, - props: { - list: Object, - disabled: Boolean, - issueLinkBase: String, - rootPath: String, - }, - data () { - return { - detailIssue: Store.detail, - filters: Store.state.filters, - }; - }, - watch: { - filters: { - handler () { - this.list.page = 1; - this.list.getIssues(true); - }, - deep: true - }, - detailIssue: { - handler () { - if (!Object.keys(this.detailIssue.issue).length) return; - - const issue = this.list.findIssue(this.detailIssue.issue.id); - - if (issue) { - const offsetLeft = this.$el.offsetLeft; - const boardsList = document.querySelectorAll('.boards-list')[0]; - const left = boardsList.scrollLeft - offsetLeft; - let right = (offsetLeft + this.$el.offsetWidth); - - if (window.innerWidth > 768 && boardsList.classList.contains('is-compact')) { - // -290 here because width of boardsList is animating so therefore - // getting the width here is incorrect - // 290 is the width of the sidebar - right -= (boardsList.offsetWidth - 290); - } else { - right -= boardsList.offsetWidth; - } - - if (right - boardsList.scrollLeft > 0) { - $(boardsList).animate({ - scrollLeft: right - }, this.sortableOptions.animation); - } else if (left > 0) { - $(boardsList).animate({ - scrollLeft: offsetLeft - }, this.sortableOptions.animation); - } - } - }, - deep: true - } - }, - methods: { - showNewIssueForm() { - this.$refs['board-list'].showIssueForm = !this.$refs['board-list'].showIssueForm; - } - }, - mounted () { - this.sortableOptions = gl.issueBoards.getBoardSortableDefaultOptions({ - disabled: this.disabled, - group: 'boards', - draggable: '.is-draggable', - handle: '.js-board-handle', - onEnd: (e) => { - gl.issueBoards.onEnd(); - - if (e.newIndex !== undefined && e.oldIndex !== e.newIndex) { - const order = this.sortable.toArray(); - const list = Store.findList('id', parseInt(e.item.dataset.id, 10)); - - this.$nextTick(() => { - Store.moveList(list, order); - }); - } - } - }); - - this.sortable = Sortable.create(this.$el.parentNode, this.sortableOptions); - }, - }); -})(); diff --git a/app/assets/javascripts/boards/components/board_blank_state.js b/app/assets/javascripts/boards/components/board_blank_state.js new file mode 100644 index 00000000000..d76314c1892 --- /dev/null +++ b/app/assets/javascripts/boards/components/board_blank_state.js @@ -0,0 +1,53 @@ +/* eslint-disable space-before-function-paren, comma-dangle */ +/* global Vue */ +/* global ListLabel */ + +(() => { + const Store = gl.issueBoards.BoardsStore; + + window.gl = window.gl || {}; + window.gl.issueBoards = window.gl.issueBoards || {}; + + gl.issueBoards.BoardBlankState = Vue.extend({ + data () { + return { + predefinedLabels: [ + new ListLabel({ title: 'To Do', color: '#F0AD4E' }), + new ListLabel({ title: 'Doing', color: '#5CB85C' }) + ] + }; + }, + methods: { + addDefaultLists () { + this.clearBlankState(); + + this.predefinedLabels.forEach((label, i) => { + Store.addList({ + title: label.title, + position: i, + list_type: 'label', + label: { + title: label.title, + color: label.color + } + }); + }); + + Store.state.lists = _.sortBy(Store.state.lists, 'position'); + + // Save the labels + gl.boardService.generateDefaultLists() + .then((resp) => { + resp.json().forEach((listObj) => { + const list = Store.findList('title', listObj.title); + + list.id = listObj.id; + list.label.id = listObj.label.id; + list.getIssues(); + }); + }); + }, + clearBlankState: Store.removeBlankState.bind(Store) + } + }); +})(); diff --git a/app/assets/javascripts/boards/components/board_blank_state.js.es6 b/app/assets/javascripts/boards/components/board_blank_state.js.es6 deleted file mode 100644 index d76314c1892..00000000000 --- a/app/assets/javascripts/boards/components/board_blank_state.js.es6 +++ /dev/null @@ -1,53 +0,0 @@ -/* eslint-disable space-before-function-paren, comma-dangle */ -/* global Vue */ -/* global ListLabel */ - -(() => { - const Store = gl.issueBoards.BoardsStore; - - window.gl = window.gl || {}; - window.gl.issueBoards = window.gl.issueBoards || {}; - - gl.issueBoards.BoardBlankState = Vue.extend({ - data () { - return { - predefinedLabels: [ - new ListLabel({ title: 'To Do', color: '#F0AD4E' }), - new ListLabel({ title: 'Doing', color: '#5CB85C' }) - ] - }; - }, - methods: { - addDefaultLists () { - this.clearBlankState(); - - this.predefinedLabels.forEach((label, i) => { - Store.addList({ - title: label.title, - position: i, - list_type: 'label', - label: { - title: label.title, - color: label.color - } - }); - }); - - Store.state.lists = _.sortBy(Store.state.lists, 'position'); - - // Save the labels - gl.boardService.generateDefaultLists() - .then((resp) => { - resp.json().forEach((listObj) => { - const list = Store.findList('title', listObj.title); - - list.id = listObj.id; - list.label.id = listObj.label.id; - list.getIssues(); - }); - }); - }, - clearBlankState: Store.removeBlankState.bind(Store) - } - }); -})(); diff --git a/app/assets/javascripts/boards/components/board_delete.js b/app/assets/javascripts/boards/components/board_delete.js new file mode 100644 index 00000000000..861600424a5 --- /dev/null +++ b/app/assets/javascripts/boards/components/board_delete.js @@ -0,0 +1,22 @@ +/* eslint-disable comma-dangle, space-before-function-paren, no-alert */ +/* global Vue */ + +(() => { + window.gl = window.gl || {}; + window.gl.issueBoards = window.gl.issueBoards || {}; + + gl.issueBoards.BoardDelete = Vue.extend({ + props: { + list: Object + }, + methods: { + deleteBoard () { + $(this.$el).tooltip('hide'); + + if (confirm('Are you sure you want to delete this list?')) { + this.list.destroy(); + } + } + } + }); +})(); diff --git a/app/assets/javascripts/boards/components/board_delete.js.es6 b/app/assets/javascripts/boards/components/board_delete.js.es6 deleted file mode 100644 index 861600424a5..00000000000 --- a/app/assets/javascripts/boards/components/board_delete.js.es6 +++ /dev/null @@ -1,22 +0,0 @@ -/* eslint-disable comma-dangle, space-before-function-paren, no-alert */ -/* global Vue */ - -(() => { - window.gl = window.gl || {}; - window.gl.issueBoards = window.gl.issueBoards || {}; - - gl.issueBoards.BoardDelete = Vue.extend({ - props: { - list: Object - }, - methods: { - deleteBoard () { - $(this.$el).tooltip('hide'); - - if (confirm('Are you sure you want to delete this list?')) { - this.list.destroy(); - } - } - } - }); -})(); diff --git a/app/assets/javascripts/boards/components/board_list.js b/app/assets/javascripts/boards/components/board_list.js new file mode 100644 index 00000000000..2d52e96e7fb --- /dev/null +++ b/app/assets/javascripts/boards/components/board_list.js @@ -0,0 +1,129 @@ +/* eslint-disable comma-dangle, space-before-function-paren, max-len */ +/* global Vue */ +/* global Sortable */ + +import boardNewIssue from './board_new_issue'; +import boardCard from './board_card'; + +(() => { + const Store = gl.issueBoards.BoardsStore; + + window.gl = window.gl || {}; + window.gl.issueBoards = window.gl.issueBoards || {}; + + gl.issueBoards.BoardList = Vue.extend({ + template: '#js-board-list-template', + components: { + boardCard, + boardNewIssue, + }, + props: { + disabled: Boolean, + list: Object, + issues: Array, + loading: Boolean, + issueLinkBase: String, + rootPath: String, + }, + data () { + return { + scrollOffset: 250, + filters: Store.state.filters, + showCount: false, + showIssueForm: false + }; + }, + watch: { + filters: { + handler () { + this.list.loadingMore = false; + this.$refs.list.scrollTop = 0; + }, + deep: true + }, + issues () { + this.$nextTick(() => { + if (this.scrollHeight() <= this.listHeight() && this.list.issuesSize > this.list.issues.length) { + this.list.page += 1; + this.list.getIssues(false); + } + + if (this.scrollHeight() > this.listHeight()) { + this.showCount = true; + } else { + this.showCount = false; + } + }); + } + }, + computed: { + orderedIssues () { + return _.sortBy(this.issues, 'priority'); + }, + }, + methods: { + listHeight () { + return this.$refs.list.getBoundingClientRect().height; + }, + scrollHeight () { + return this.$refs.list.scrollHeight; + }, + scrollTop () { + return this.$refs.list.scrollTop + this.listHeight(); + }, + loadNextPage () { + const getIssues = this.list.nextPage(); + + if (getIssues) { + this.list.loadingMore = true; + getIssues.then(() => { + this.list.loadingMore = false; + }); + } + }, + toggleForm() { + this.showIssueForm = !this.showIssueForm; + }, + }, + created() { + gl.IssueBoardsApp.$on(`hide-issue-form-${this.list.id}`, this.toggleForm); + }, + mounted () { + const options = gl.issueBoards.getBoardSortableDefaultOptions({ + scroll: document.querySelectorAll('.boards-list')[0], + group: 'issues', + sort: false, + disabled: this.disabled, + filter: '.board-list-count, .is-disabled', + onStart: (e) => { + const card = this.$refs.issue[e.oldIndex]; + + card.showDetail = false; + Store.moving.list = card.list; + Store.moving.issue = Store.moving.list.findIssue(+e.item.dataset.issueId); + + gl.issueBoards.onStart(); + }, + onAdd: (e) => { + gl.issueBoards.BoardsStore.moveIssueToList(Store.moving.list, this.list, Store.moving.issue, e.newIndex); + + this.$nextTick(() => { + e.item.remove(); + }); + }, + }); + + this.sortable = Sortable.create(this.$refs.list, options); + + // Scroll event on list to load more + this.$refs.list.onscroll = () => { + if ((this.scrollTop() > this.scrollHeight() - this.scrollOffset) && !this.list.loadingMore) { + this.loadNextPage(); + } + }; + }, + beforeDestroy() { + gl.IssueBoardsApp.$off(`hide-issue-form-${this.list.id}`, this.toggleForm); + }, + }); +})(); diff --git a/app/assets/javascripts/boards/components/board_list.js.es6 b/app/assets/javascripts/boards/components/board_list.js.es6 deleted file mode 100644 index 2d52e96e7fb..00000000000 --- a/app/assets/javascripts/boards/components/board_list.js.es6 +++ /dev/null @@ -1,129 +0,0 @@ -/* eslint-disable comma-dangle, space-before-function-paren, max-len */ -/* global Vue */ -/* global Sortable */ - -import boardNewIssue from './board_new_issue'; -import boardCard from './board_card'; - -(() => { - const Store = gl.issueBoards.BoardsStore; - - window.gl = window.gl || {}; - window.gl.issueBoards = window.gl.issueBoards || {}; - - gl.issueBoards.BoardList = Vue.extend({ - template: '#js-board-list-template', - components: { - boardCard, - boardNewIssue, - }, - props: { - disabled: Boolean, - list: Object, - issues: Array, - loading: Boolean, - issueLinkBase: String, - rootPath: String, - }, - data () { - return { - scrollOffset: 250, - filters: Store.state.filters, - showCount: false, - showIssueForm: false - }; - }, - watch: { - filters: { - handler () { - this.list.loadingMore = false; - this.$refs.list.scrollTop = 0; - }, - deep: true - }, - issues () { - this.$nextTick(() => { - if (this.scrollHeight() <= this.listHeight() && this.list.issuesSize > this.list.issues.length) { - this.list.page += 1; - this.list.getIssues(false); - } - - if (this.scrollHeight() > this.listHeight()) { - this.showCount = true; - } else { - this.showCount = false; - } - }); - } - }, - computed: { - orderedIssues () { - return _.sortBy(this.issues, 'priority'); - }, - }, - methods: { - listHeight () { - return this.$refs.list.getBoundingClientRect().height; - }, - scrollHeight () { - return this.$refs.list.scrollHeight; - }, - scrollTop () { - return this.$refs.list.scrollTop + this.listHeight(); - }, - loadNextPage () { - const getIssues = this.list.nextPage(); - - if (getIssues) { - this.list.loadingMore = true; - getIssues.then(() => { - this.list.loadingMore = false; - }); - } - }, - toggleForm() { - this.showIssueForm = !this.showIssueForm; - }, - }, - created() { - gl.IssueBoardsApp.$on(`hide-issue-form-${this.list.id}`, this.toggleForm); - }, - mounted () { - const options = gl.issueBoards.getBoardSortableDefaultOptions({ - scroll: document.querySelectorAll('.boards-list')[0], - group: 'issues', - sort: false, - disabled: this.disabled, - filter: '.board-list-count, .is-disabled', - onStart: (e) => { - const card = this.$refs.issue[e.oldIndex]; - - card.showDetail = false; - Store.moving.list = card.list; - Store.moving.issue = Store.moving.list.findIssue(+e.item.dataset.issueId); - - gl.issueBoards.onStart(); - }, - onAdd: (e) => { - gl.issueBoards.BoardsStore.moveIssueToList(Store.moving.list, this.list, Store.moving.issue, e.newIndex); - - this.$nextTick(() => { - e.item.remove(); - }); - }, - }); - - this.sortable = Sortable.create(this.$refs.list, options); - - // Scroll event on list to load more - this.$refs.list.onscroll = () => { - if ((this.scrollTop() > this.scrollHeight() - this.scrollOffset) && !this.list.loadingMore) { - this.loadNextPage(); - } - }; - }, - beforeDestroy() { - gl.IssueBoardsApp.$off(`hide-issue-form-${this.list.id}`, this.toggleForm); - }, - }); -})(); diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js new file mode 100644 index 00000000000..dfc6eed785c --- /dev/null +++ b/app/assets/javascripts/boards/components/board_sidebar.js @@ -0,0 +1,72 @@ +/* eslint-disable comma-dangle, space-before-function-paren, no-new */ +/* global Vue */ +/* global IssuableContext */ +/* global MilestoneSelect */ +/* global LabelsSelect */ +/* global Sidebar */ + +require('./sidebar/remove_issue'); + +(() => { + const Store = gl.issueBoards.BoardsStore; + + window.gl = window.gl || {}; + window.gl.issueBoards = window.gl.issueBoards || {}; + + gl.issueBoards.BoardSidebar = Vue.extend({ + props: { + currentUser: Object + }, + data() { + return { + detail: Store.detail, + issue: {}, + list: {}, + }; + }, + computed: { + showSidebar () { + return Object.keys(this.issue).length; + } + }, + watch: { + detail: { + handler () { + if (this.issue.id !== this.detail.issue.id) { + $('.js-issue-board-sidebar', this.$el).each((i, el) => { + $(el).data('glDropdown').clearMenu(); + }); + } + + this.issue = this.detail.issue; + this.list = this.detail.list; + }, + deep: true + }, + issue () { + if (this.showSidebar) { + this.$nextTick(() => { + $('.right-sidebar').getNiceScroll(0).doScrollTop(0, 0); + $('.right-sidebar').getNiceScroll().resize(); + }); + } + } + }, + methods: { + closeSidebar () { + this.detail.issue = {}; + } + }, + mounted () { + new IssuableContext(this.currentUser); + new MilestoneSelect(); + new gl.DueDateSelectors(); + new LabelsSelect(); + new Sidebar(); + gl.Subscription.bindAll('.subscription'); + }, + components: { + removeBtn: gl.issueBoards.RemoveIssueBtn, + }, + }); +})(); diff --git a/app/assets/javascripts/boards/components/board_sidebar.js.es6 b/app/assets/javascripts/boards/components/board_sidebar.js.es6 deleted file mode 100644 index dfc6eed785c..00000000000 --- a/app/assets/javascripts/boards/components/board_sidebar.js.es6 +++ /dev/null @@ -1,72 +0,0 @@ -/* eslint-disable comma-dangle, space-before-function-paren, no-new */ -/* global Vue */ -/* global IssuableContext */ -/* global MilestoneSelect */ -/* global LabelsSelect */ -/* global Sidebar */ - -require('./sidebar/remove_issue'); - -(() => { - const Store = gl.issueBoards.BoardsStore; - - window.gl = window.gl || {}; - window.gl.issueBoards = window.gl.issueBoards || {}; - - gl.issueBoards.BoardSidebar = Vue.extend({ - props: { - currentUser: Object - }, - data() { - return { - detail: Store.detail, - issue: {}, - list: {}, - }; - }, - computed: { - showSidebar () { - return Object.keys(this.issue).length; - } - }, - watch: { - detail: { - handler () { - if (this.issue.id !== this.detail.issue.id) { - $('.js-issue-board-sidebar', this.$el).each((i, el) => { - $(el).data('glDropdown').clearMenu(); - }); - } - - this.issue = this.detail.issue; - this.list = this.detail.list; - }, - deep: true - }, - issue () { - if (this.showSidebar) { - this.$nextTick(() => { - $('.right-sidebar').getNiceScroll(0).doScrollTop(0, 0); - $('.right-sidebar').getNiceScroll().resize(); - }); - } - } - }, - methods: { - closeSidebar () { - this.detail.issue = {}; - } - }, - mounted () { - new IssuableContext(this.currentUser); - new MilestoneSelect(); - new gl.DueDateSelectors(); - new LabelsSelect(); - new Sidebar(); - gl.Subscription.bindAll('.subscription'); - }, - components: { - removeBtn: gl.issueBoards.RemoveIssueBtn, - }, - }); -})(); diff --git a/app/assets/javascripts/boards/components/issue_card_inner.js b/app/assets/javascripts/boards/components/issue_card_inner.js new file mode 100644 index 00000000000..22a8b971ff8 --- /dev/null +++ b/app/assets/javascripts/boards/components/issue_card_inner.js @@ -0,0 +1,111 @@ +/* global Vue */ +(() => { + const Store = gl.issueBoards.BoardsStore; + + window.gl = window.gl || {}; + window.gl.issueBoards = window.gl.issueBoards || {}; + + gl.issueBoards.IssueCardInner = Vue.extend({ + props: { + issue: { + type: Object, + required: true, + }, + issueLinkBase: { + type: String, + required: true, + }, + list: { + type: Object, + required: false, + }, + rootPath: { + type: String, + required: true, + }, + }, + methods: { + showLabel(label) { + if (!this.list) return true; + + 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); + $(e.currentTarget).tooltip('hide'); + + if (labelIndex === -1) { + Store.state.filters.label_name.push(label.title); + $('.labels-filter').prepend(``); + } 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(); + } + + 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); + + Store.updateFiltersUrl(); + }, + labelStyle(label) { + return { + backgroundColor: label.color, + color: label.textColor, + }; + }, + }, + template: ` +
+

+ + + {{ issue.title }} + +

+ +
+ `, + }); +})(); diff --git a/app/assets/javascripts/boards/components/issue_card_inner.js.es6 b/app/assets/javascripts/boards/components/issue_card_inner.js.es6 deleted file mode 100644 index 22a8b971ff8..00000000000 --- a/app/assets/javascripts/boards/components/issue_card_inner.js.es6 +++ /dev/null @@ -1,111 +0,0 @@ -/* global Vue */ -(() => { - const Store = gl.issueBoards.BoardsStore; - - window.gl = window.gl || {}; - window.gl.issueBoards = window.gl.issueBoards || {}; - - gl.issueBoards.IssueCardInner = Vue.extend({ - props: { - issue: { - type: Object, - required: true, - }, - issueLinkBase: { - type: String, - required: true, - }, - list: { - type: Object, - required: false, - }, - rootPath: { - type: String, - required: true, - }, - }, - methods: { - showLabel(label) { - if (!this.list) return true; - - 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); - $(e.currentTarget).tooltip('hide'); - - if (labelIndex === -1) { - Store.state.filters.label_name.push(label.title); - $('.labels-filter').prepend(``); - } 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(); - } - - 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); - - Store.updateFiltersUrl(); - }, - labelStyle(label) { - return { - backgroundColor: label.color, - color: label.textColor, - }; - }, - }, - template: ` -
-

- - - {{ issue.title }} - -

- -
- `, - }); -})(); diff --git a/app/assets/javascripts/boards/components/modal/empty_state.js b/app/assets/javascripts/boards/components/modal/empty_state.js new file mode 100644 index 00000000000..9538f5b69e9 --- /dev/null +++ b/app/assets/javascripts/boards/components/modal/empty_state.js @@ -0,0 +1,70 @@ +/* global Vue */ +(() => { + const ModalStore = gl.issueBoards.ModalStore; + + gl.issueBoards.ModalEmptyState = Vue.extend({ + mixins: [gl.issueBoards.ModalMixins], + data() { + return ModalStore.store; + }, + props: { + image: { + type: String, + required: true, + }, + newIssuePath: { + type: String, + required: true, + }, + }, + computed: { + contents() { + const obj = { + title: 'You haven\'t added any issues to your project yet', + content: ` + An issue can be a bug, a todo or a feature request that needs to be + discussed in a project. Besides, issues are searchable and filterable. + `, + }; + + if (this.activeTab === 'selected') { + obj.title = 'You haven\'t selected any issues yet'; + obj.content = ` + Go back to All issues and select some issues + to add to your board. + `; + } + + return obj; + }, + }, + template: ` +
+
+
+ +
+
+
+

{{ contents.title }}

+

+ + New issue + + +
+
+
+
+ `, + }); +})(); diff --git a/app/assets/javascripts/boards/components/modal/empty_state.js.es6 b/app/assets/javascripts/boards/components/modal/empty_state.js.es6 deleted file mode 100644 index 9538f5b69e9..00000000000 --- a/app/assets/javascripts/boards/components/modal/empty_state.js.es6 +++ /dev/null @@ -1,70 +0,0 @@ -/* global Vue */ -(() => { - const ModalStore = gl.issueBoards.ModalStore; - - gl.issueBoards.ModalEmptyState = Vue.extend({ - mixins: [gl.issueBoards.ModalMixins], - data() { - return ModalStore.store; - }, - props: { - image: { - type: String, - required: true, - }, - newIssuePath: { - type: String, - required: true, - }, - }, - computed: { - contents() { - const obj = { - title: 'You haven\'t added any issues to your project yet', - content: ` - An issue can be a bug, a todo or a feature request that needs to be - discussed in a project. Besides, issues are searchable and filterable. - `, - }; - - if (this.activeTab === 'selected') { - obj.title = 'You haven\'t selected any issues yet'; - obj.content = ` - Go back to All issues and select some issues - to add to your board. - `; - } - - return obj; - }, - }, - template: ` -
-
-
- -
-
-
-

{{ contents.title }}

-

- - New issue - - -
-
-
-
- `, - }); -})(); diff --git a/app/assets/javascripts/boards/components/modal/filters.js b/app/assets/javascripts/boards/components/modal/filters.js new file mode 100644 index 00000000000..6de06811d94 --- /dev/null +++ b/app/assets/javascripts/boards/components/modal/filters.js @@ -0,0 +1,49 @@ +/* global Vue */ +const userFilter = require('./filters/user'); +const milestoneFilter = require('./filters/milestone'); +const labelFilter = require('./filters/label'); + +module.exports = Vue.extend({ + name: 'modal-filters', + props: { + projectId: { + type: Number, + required: true, + }, + milestonePath: { + type: String, + required: true, + }, + labelPath: { + type: String, + required: true, + }, + }, + destroyed() { + gl.issueBoards.ModalStore.setDefaultFilter(); + }, + components: { + userFilter, + milestoneFilter, + labelFilter, + }, + template: ` + + `, +}); diff --git a/app/assets/javascripts/boards/components/modal/filters.js.es6 b/app/assets/javascripts/boards/components/modal/filters.js.es6 deleted file mode 100644 index 6de06811d94..00000000000 --- a/app/assets/javascripts/boards/components/modal/filters.js.es6 +++ /dev/null @@ -1,49 +0,0 @@ -/* global Vue */ -const userFilter = require('./filters/user'); -const milestoneFilter = require('./filters/milestone'); -const labelFilter = require('./filters/label'); - -module.exports = Vue.extend({ - name: 'modal-filters', - props: { - projectId: { - type: Number, - required: true, - }, - milestonePath: { - type: String, - required: true, - }, - labelPath: { - type: String, - required: true, - }, - }, - destroyed() { - gl.issueBoards.ModalStore.setDefaultFilter(); - }, - components: { - userFilter, - milestoneFilter, - labelFilter, - }, - template: ` - - `, -}); diff --git a/app/assets/javascripts/boards/components/modal/filters/label.js b/app/assets/javascripts/boards/components/modal/filters/label.js new file mode 100644 index 00000000000..4fc8f72a145 --- /dev/null +++ b/app/assets/javascripts/boards/components/modal/filters/label.js @@ -0,0 +1,54 @@ +/* eslint-disable no-new */ +/* global Vue */ +/* global LabelsSelect */ +module.exports = Vue.extend({ + name: 'filter-label', + props: { + labelPath: { + type: String, + required: true, + }, + }, + mounted() { + new LabelsSelect(this.$refs.dropdown); + }, + template: ` + + `, +}); diff --git a/app/assets/javascripts/boards/components/modal/filters/label.js.es6 b/app/assets/javascripts/boards/components/modal/filters/label.js.es6 deleted file mode 100644 index 4fc8f72a145..00000000000 --- a/app/assets/javascripts/boards/components/modal/filters/label.js.es6 +++ /dev/null @@ -1,54 +0,0 @@ -/* eslint-disable no-new */ -/* global Vue */ -/* global LabelsSelect */ -module.exports = Vue.extend({ - name: 'filter-label', - props: { - labelPath: { - type: String, - required: true, - }, - }, - mounted() { - new LabelsSelect(this.$refs.dropdown); - }, - template: ` - - `, -}); diff --git a/app/assets/javascripts/boards/components/modal/filters/milestone.js b/app/assets/javascripts/boards/components/modal/filters/milestone.js new file mode 100644 index 00000000000..d555599d300 --- /dev/null +++ b/app/assets/javascripts/boards/components/modal/filters/milestone.js @@ -0,0 +1,55 @@ +/* eslint-disable no-new */ +/* global Vue */ +/* global MilestoneSelect */ +module.exports = Vue.extend({ + name: 'filter-milestone', + props: { + milestonePath: { + type: String, + required: true, + }, + }, + mounted() { + new MilestoneSelect(null, this.$refs.dropdown); + }, + template: ` + + `, +}); diff --git a/app/assets/javascripts/boards/components/modal/filters/milestone.js.es6 b/app/assets/javascripts/boards/components/modal/filters/milestone.js.es6 deleted file mode 100644 index d555599d300..00000000000 --- a/app/assets/javascripts/boards/components/modal/filters/milestone.js.es6 +++ /dev/null @@ -1,55 +0,0 @@ -/* eslint-disable no-new */ -/* global Vue */ -/* global MilestoneSelect */ -module.exports = Vue.extend({ - name: 'filter-milestone', - props: { - milestonePath: { - type: String, - required: true, - }, - }, - mounted() { - new MilestoneSelect(null, this.$refs.dropdown); - }, - template: ` - - `, -}); diff --git a/app/assets/javascripts/boards/components/modal/filters/user.js b/app/assets/javascripts/boards/components/modal/filters/user.js new file mode 100644 index 00000000000..8523028c29c --- /dev/null +++ b/app/assets/javascripts/boards/components/modal/filters/user.js @@ -0,0 +1,96 @@ +/* eslint-disable no-new */ +/* global Vue */ +/* global UsersSelect */ +module.exports = Vue.extend({ + name: 'filter-user', + props: { + toggleClassName: { + type: String, + required: true, + }, + dropdownClassName: { + type: String, + required: false, + default: '', + }, + toggleLabel: { + type: String, + required: true, + }, + fieldName: { + type: String, + required: true, + }, + nullUser: { + type: Boolean, + required: false, + default: false, + }, + projectId: { + type: Number, + required: true, + }, + }, + mounted() { + new UsersSelect(null, this.$refs.dropdown); + }, + computed: { + currentUsername() { + return gon.current_username; + }, + dropdownTitle() { + return `Filter by ${this.toggleLabel.toLowerCase()}`; + }, + inputPlaceholder() { + return `Search ${this.toggleLabel.toLowerCase()}`; + }, + }, + template: ` + + `, +}); diff --git a/app/assets/javascripts/boards/components/modal/filters/user.js.es6 b/app/assets/javascripts/boards/components/modal/filters/user.js.es6 deleted file mode 100644 index 8523028c29c..00000000000 --- a/app/assets/javascripts/boards/components/modal/filters/user.js.es6 +++ /dev/null @@ -1,96 +0,0 @@ -/* eslint-disable no-new */ -/* global Vue */ -/* global UsersSelect */ -module.exports = Vue.extend({ - name: 'filter-user', - props: { - toggleClassName: { - type: String, - required: true, - }, - dropdownClassName: { - type: String, - required: false, - default: '', - }, - toggleLabel: { - type: String, - required: true, - }, - fieldName: { - type: String, - required: true, - }, - nullUser: { - type: Boolean, - required: false, - default: false, - }, - projectId: { - type: Number, - required: true, - }, - }, - mounted() { - new UsersSelect(null, this.$refs.dropdown); - }, - computed: { - currentUsername() { - return gon.current_username; - }, - dropdownTitle() { - return `Filter by ${this.toggleLabel.toLowerCase()}`; - }, - inputPlaceholder() { - return `Search ${this.toggleLabel.toLowerCase()}`; - }, - }, - template: ` - - `, -}); diff --git a/app/assets/javascripts/boards/components/modal/footer.js b/app/assets/javascripts/boards/components/modal/footer.js new file mode 100644 index 00000000000..1cbc422c961 --- /dev/null +++ b/app/assets/javascripts/boards/components/modal/footer.js @@ -0,0 +1,83 @@ +/* eslint-disable no-new */ +/* global Vue */ +/* global Flash */ + +require('./lists_dropdown'); + +(() => { + const ModalStore = gl.issueBoards.ModalStore; + + gl.issueBoards.ModalFooter = Vue.extend({ + mixins: [gl.issueBoards.ModalMixins], + data() { + return { + modal: ModalStore.store, + state: gl.issueBoards.BoardsStore.state, + }; + }, + computed: { + submitDisabled() { + return !ModalStore.selectedCount(); + }, + submitText() { + const count = ModalStore.selectedCount(); + + return `Add ${count > 0 ? count : ''} ${gl.text.pluralize('issue', count)}`; + }, + }, + methods: { + addIssues() { + const list = this.modal.selectedList || this.state.lists[0]; + const selectedIssues = ModalStore.getSelectedIssues(); + const issueIds = selectedIssues.map(issue => issue.globalId); + + // Post the data to the backend + gl.boardService.bulkUpdate(issueIds, { + add_label_ids: [list.label.id], + }).catch(() => { + new Flash('Failed to update issues, please try again.', 'alert'); + + selectedIssues.forEach((issue) => { + list.removeIssue(issue); + list.issuesSize -= 1; + }); + }); + + // Add the issues on the frontend + selectedIssues.forEach((issue) => { + list.addIssue(issue); + list.issuesSize += 1; + }); + + this.toggleModal(false); + }, + }, + components: { + 'lists-dropdown': gl.issueBoards.ModalFooterListsDropdown, + }, + template: ` +
+
+ + + to list + + +
+ +
+ `, + }); +})(); diff --git a/app/assets/javascripts/boards/components/modal/footer.js.es6 b/app/assets/javascripts/boards/components/modal/footer.js.es6 deleted file mode 100644 index 1cbc422c961..00000000000 --- a/app/assets/javascripts/boards/components/modal/footer.js.es6 +++ /dev/null @@ -1,83 +0,0 @@ -/* eslint-disable no-new */ -/* global Vue */ -/* global Flash */ - -require('./lists_dropdown'); - -(() => { - const ModalStore = gl.issueBoards.ModalStore; - - gl.issueBoards.ModalFooter = Vue.extend({ - mixins: [gl.issueBoards.ModalMixins], - data() { - return { - modal: ModalStore.store, - state: gl.issueBoards.BoardsStore.state, - }; - }, - computed: { - submitDisabled() { - return !ModalStore.selectedCount(); - }, - submitText() { - const count = ModalStore.selectedCount(); - - return `Add ${count > 0 ? count : ''} ${gl.text.pluralize('issue', count)}`; - }, - }, - methods: { - addIssues() { - const list = this.modal.selectedList || this.state.lists[0]; - const selectedIssues = ModalStore.getSelectedIssues(); - const issueIds = selectedIssues.map(issue => issue.globalId); - - // Post the data to the backend - gl.boardService.bulkUpdate(issueIds, { - add_label_ids: [list.label.id], - }).catch(() => { - new Flash('Failed to update issues, please try again.', 'alert'); - - selectedIssues.forEach((issue) => { - list.removeIssue(issue); - list.issuesSize -= 1; - }); - }); - - // Add the issues on the frontend - selectedIssues.forEach((issue) => { - list.addIssue(issue); - list.issuesSize += 1; - }); - - this.toggleModal(false); - }, - }, - components: { - 'lists-dropdown': gl.issueBoards.ModalFooterListsDropdown, - }, - template: ` -
-
- - - to list - - -
- -
- `, - }); -})(); diff --git a/app/assets/javascripts/boards/components/modal/header.js b/app/assets/javascripts/boards/components/modal/header.js new file mode 100644 index 00000000000..70c088f9054 --- /dev/null +++ b/app/assets/javascripts/boards/components/modal/header.js @@ -0,0 +1,90 @@ +/* global Vue */ +require('./tabs'); +const modalFilters = require('./filters'); + +(() => { + const ModalStore = gl.issueBoards.ModalStore; + + gl.issueBoards.ModalHeader = Vue.extend({ + mixins: [gl.issueBoards.ModalMixins], + props: { + projectId: { + type: Number, + required: true, + }, + milestonePath: { + type: String, + required: true, + }, + labelPath: { + type: String, + required: true, + }, + }, + data() { + return ModalStore.store; + }, + computed: { + selectAllText() { + if (ModalStore.selectedCount() !== this.issues.length || this.issues.length === 0) { + return 'Select all'; + } + + return 'Deselect all'; + }, + showSearch() { + return this.activeTab === 'all' && !this.loading && this.issuesCount > 0; + }, + }, + methods: { + toggleAll() { + this.$refs.selectAllBtn.blur(); + + ModalStore.toggleAll(); + }, + }, + components: { + 'modal-tabs': gl.issueBoards.ModalTabs, + modalFilters, + }, + template: ` +
+
+

+ Add issues + +

+
+ + +
+ `, + }); +})(); diff --git a/app/assets/javascripts/boards/components/modal/header.js.es6 b/app/assets/javascripts/boards/components/modal/header.js.es6 deleted file mode 100644 index 70c088f9054..00000000000 --- a/app/assets/javascripts/boards/components/modal/header.js.es6 +++ /dev/null @@ -1,90 +0,0 @@ -/* global Vue */ -require('./tabs'); -const modalFilters = require('./filters'); - -(() => { - const ModalStore = gl.issueBoards.ModalStore; - - gl.issueBoards.ModalHeader = Vue.extend({ - mixins: [gl.issueBoards.ModalMixins], - props: { - projectId: { - type: Number, - required: true, - }, - milestonePath: { - type: String, - required: true, - }, - labelPath: { - type: String, - required: true, - }, - }, - data() { - return ModalStore.store; - }, - computed: { - selectAllText() { - if (ModalStore.selectedCount() !== this.issues.length || this.issues.length === 0) { - return 'Select all'; - } - - return 'Deselect all'; - }, - showSearch() { - return this.activeTab === 'all' && !this.loading && this.issuesCount > 0; - }, - }, - methods: { - toggleAll() { - this.$refs.selectAllBtn.blur(); - - ModalStore.toggleAll(); - }, - }, - components: { - 'modal-tabs': gl.issueBoards.ModalTabs, - modalFilters, - }, - template: ` -
-
-

- Add issues - -

-
- - -
- `, - }); -})(); diff --git a/app/assets/javascripts/boards/components/modal/index.js b/app/assets/javascripts/boards/components/modal/index.js new file mode 100644 index 00000000000..f290cd13763 --- /dev/null +++ b/app/assets/javascripts/boards/components/modal/index.js @@ -0,0 +1,163 @@ +/* global Vue */ +/* global ListIssue */ + +require('./header'); +require('./list'); +require('./footer'); +require('./empty_state'); + +(() => { + const ModalStore = gl.issueBoards.ModalStore; + + gl.issueBoards.IssuesModal = Vue.extend({ + props: { + blankStateImage: { + type: String, + required: true, + }, + newIssuePath: { + type: String, + required: true, + }, + issueLinkBase: { + type: String, + required: true, + }, + rootPath: { + type: String, + required: true, + }, + projectId: { + type: Number, + required: true, + }, + milestonePath: { + type: String, + required: true, + }, + labelPath: { + type: String, + required: true, + }, + }, + data() { + return ModalStore.store; + }, + watch: { + page() { + this.loadIssues(); + }, + searchTerm() { + this.searchOperation(); + }, + showAddIssuesModal() { + if (this.showAddIssuesModal && !this.issues.length) { + this.loading = true; + + this.loadIssues() + .then(() => { + this.loading = false; + }); + } else if (!this.showAddIssuesModal) { + this.issues = []; + this.selectedIssues = []; + this.issuesCount = false; + } + }, + filter: { + handler() { + this.loadIssues(true); + }, + deep: true, + }, + }, + methods: { + searchOperation: _.debounce(function searchOperationDebounce() { + this.loadIssues(true); + }, 500), + loadIssues(clearIssues = false) { + if (!this.showAddIssuesModal) return false; + + const queryData = Object.assign({}, this.filter, { + search: this.searchTerm, + page: this.page, + per: this.perPage, + }); + + return gl.boardService.getBacklog(queryData).then((res) => { + const data = res.json(); + + if (clearIssues) { + this.issues = []; + } + + data.issues.forEach((issueObj) => { + const issue = new ListIssue(issueObj); + const foundSelectedIssue = ModalStore.findSelectedIssue(issue); + issue.selected = !!foundSelectedIssue; + + this.issues.push(issue); + }); + + this.loadingNewPage = false; + + if (!this.issuesCount) { + this.issuesCount = data.size; + } + }); + }, + }, + computed: { + showList() { + if (this.activeTab === 'selected') { + return this.selectedIssues.length > 0; + } + + return this.issuesCount > 0; + }, + showEmptyState() { + if (!this.loading && this.issuesCount === 0) { + return true; + } + + return this.activeTab === 'selected' && this.selectedIssues.length === 0; + }, + }, + components: { + 'modal-header': gl.issueBoards.ModalHeader, + 'modal-list': gl.issueBoards.ModalList, + 'modal-footer': gl.issueBoards.ModalFooter, + 'empty-state': gl.issueBoards.ModalEmptyState, + }, + template: ` +
+
+ + + + +
+
+ +
+
+ +
+
+ `, + }); +})(); diff --git a/app/assets/javascripts/boards/components/modal/index.js.es6 b/app/assets/javascripts/boards/components/modal/index.js.es6 deleted file mode 100644 index f290cd13763..00000000000 --- a/app/assets/javascripts/boards/components/modal/index.js.es6 +++ /dev/null @@ -1,163 +0,0 @@ -/* global Vue */ -/* global ListIssue */ - -require('./header'); -require('./list'); -require('./footer'); -require('./empty_state'); - -(() => { - const ModalStore = gl.issueBoards.ModalStore; - - gl.issueBoards.IssuesModal = Vue.extend({ - props: { - blankStateImage: { - type: String, - required: true, - }, - newIssuePath: { - type: String, - required: true, - }, - issueLinkBase: { - type: String, - required: true, - }, - rootPath: { - type: String, - required: true, - }, - projectId: { - type: Number, - required: true, - }, - milestonePath: { - type: String, - required: true, - }, - labelPath: { - type: String, - required: true, - }, - }, - data() { - return ModalStore.store; - }, - watch: { - page() { - this.loadIssues(); - }, - searchTerm() { - this.searchOperation(); - }, - showAddIssuesModal() { - if (this.showAddIssuesModal && !this.issues.length) { - this.loading = true; - - this.loadIssues() - .then(() => { - this.loading = false; - }); - } else if (!this.showAddIssuesModal) { - this.issues = []; - this.selectedIssues = []; - this.issuesCount = false; - } - }, - filter: { - handler() { - this.loadIssues(true); - }, - deep: true, - }, - }, - methods: { - searchOperation: _.debounce(function searchOperationDebounce() { - this.loadIssues(true); - }, 500), - loadIssues(clearIssues = false) { - if (!this.showAddIssuesModal) return false; - - const queryData = Object.assign({}, this.filter, { - search: this.searchTerm, - page: this.page, - per: this.perPage, - }); - - return gl.boardService.getBacklog(queryData).then((res) => { - const data = res.json(); - - if (clearIssues) { - this.issues = []; - } - - data.issues.forEach((issueObj) => { - const issue = new ListIssue(issueObj); - const foundSelectedIssue = ModalStore.findSelectedIssue(issue); - issue.selected = !!foundSelectedIssue; - - this.issues.push(issue); - }); - - this.loadingNewPage = false; - - if (!this.issuesCount) { - this.issuesCount = data.size; - } - }); - }, - }, - computed: { - showList() { - if (this.activeTab === 'selected') { - return this.selectedIssues.length > 0; - } - - return this.issuesCount > 0; - }, - showEmptyState() { - if (!this.loading && this.issuesCount === 0) { - return true; - } - - return this.activeTab === 'selected' && this.selectedIssues.length === 0; - }, - }, - components: { - 'modal-header': gl.issueBoards.ModalHeader, - 'modal-list': gl.issueBoards.ModalList, - 'modal-footer': gl.issueBoards.ModalFooter, - 'empty-state': gl.issueBoards.ModalEmptyState, - }, - template: ` -
-
- - - - -
-
- -
-
- -
-
- `, - }); -})(); diff --git a/app/assets/javascripts/boards/components/modal/list.js b/app/assets/javascripts/boards/components/modal/list.js new file mode 100644 index 00000000000..3730c1ecaeb --- /dev/null +++ b/app/assets/javascripts/boards/components/modal/list.js @@ -0,0 +1,159 @@ +/* global Vue */ +/* global ListIssue */ +/* global bp */ +(() => { + const ModalStore = gl.issueBoards.ModalStore; + + gl.issueBoards.ModalList = Vue.extend({ + props: { + issueLinkBase: { + type: String, + required: true, + }, + rootPath: { + type: String, + required: true, + }, + image: { + type: String, + required: true, + }, + }, + data() { + return ModalStore.store; + }, + watch: { + activeTab() { + if (this.activeTab === 'all') { + ModalStore.purgeUnselectedIssues(); + } + }, + }, + computed: { + loopIssues() { + if (this.activeTab === 'all') { + return this.issues; + } + + return this.selectedIssues; + }, + groupedIssues() { + const groups = []; + this.loopIssues.forEach((issue, i) => { + const index = i % this.columns; + + if (!groups[index]) { + groups.push([]); + } + + groups[index].push(issue); + }); + + return groups; + }, + }, + methods: { + scrollHandler() { + const currentPage = Math.floor(this.issues.length / this.perPage); + + if ((this.scrollTop() > this.scrollHeight() - 100) && !this.loadingNewPage + && currentPage === this.page) { + this.loadingNewPage = true; + this.page += 1; + } + }, + toggleIssue(e, issue) { + if (e.target.tagName !== 'A') { + ModalStore.toggleIssue(issue); + } + }, + listHeight() { + return this.$refs.list.getBoundingClientRect().height; + }, + scrollHeight() { + return this.$refs.list.scrollHeight; + }, + scrollTop() { + return this.$refs.list.scrollTop + this.listHeight(); + }, + showIssue(issue) { + if (this.activeTab === 'all') return true; + + const index = ModalStore.selectedIssueIndex(issue); + + return index !== -1; + }, + setColumnCount() { + const breakpoint = bp.getBreakpointSize(); + + if (breakpoint === 'lg' || breakpoint === 'md') { + this.columns = 3; + } else if (breakpoint === 'sm') { + this.columns = 2; + } else { + this.columns = 1; + } + }, + }, + mounted() { + this.scrollHandlerWrapper = this.scrollHandler.bind(this); + this.setColumnCountWrapper = this.setColumnCount.bind(this); + this.setColumnCount(); + + this.$refs.list.addEventListener('scroll', this.scrollHandlerWrapper); + window.addEventListener('resize', this.setColumnCountWrapper); + }, + beforeDestroy() { + this.$refs.list.removeEventListener('scroll', this.scrollHandlerWrapper); + window.removeEventListener('resize', this.setColumnCountWrapper); + }, + components: { + 'issue-card-inner': gl.issueBoards.IssueCardInner, + }, + template: ` +
+
+
+
+
+

+ There are no issues to show. +

+
+
+
+
+
+ + + + + +
+
+
+
+ `, + }); +})(); diff --git a/app/assets/javascripts/boards/components/modal/list.js.es6 b/app/assets/javascripts/boards/components/modal/list.js.es6 deleted file mode 100644 index 3730c1ecaeb..00000000000 --- a/app/assets/javascripts/boards/components/modal/list.js.es6 +++ /dev/null @@ -1,159 +0,0 @@ -/* global Vue */ -/* global ListIssue */ -/* global bp */ -(() => { - const ModalStore = gl.issueBoards.ModalStore; - - gl.issueBoards.ModalList = Vue.extend({ - props: { - issueLinkBase: { - type: String, - required: true, - }, - rootPath: { - type: String, - required: true, - }, - image: { - type: String, - required: true, - }, - }, - data() { - return ModalStore.store; - }, - watch: { - activeTab() { - if (this.activeTab === 'all') { - ModalStore.purgeUnselectedIssues(); - } - }, - }, - computed: { - loopIssues() { - if (this.activeTab === 'all') { - return this.issues; - } - - return this.selectedIssues; - }, - groupedIssues() { - const groups = []; - this.loopIssues.forEach((issue, i) => { - const index = i % this.columns; - - if (!groups[index]) { - groups.push([]); - } - - groups[index].push(issue); - }); - - return groups; - }, - }, - methods: { - scrollHandler() { - const currentPage = Math.floor(this.issues.length / this.perPage); - - if ((this.scrollTop() > this.scrollHeight() - 100) && !this.loadingNewPage - && currentPage === this.page) { - this.loadingNewPage = true; - this.page += 1; - } - }, - toggleIssue(e, issue) { - if (e.target.tagName !== 'A') { - ModalStore.toggleIssue(issue); - } - }, - listHeight() { - return this.$refs.list.getBoundingClientRect().height; - }, - scrollHeight() { - return this.$refs.list.scrollHeight; - }, - scrollTop() { - return this.$refs.list.scrollTop + this.listHeight(); - }, - showIssue(issue) { - if (this.activeTab === 'all') return true; - - const index = ModalStore.selectedIssueIndex(issue); - - return index !== -1; - }, - setColumnCount() { - const breakpoint = bp.getBreakpointSize(); - - if (breakpoint === 'lg' || breakpoint === 'md') { - this.columns = 3; - } else if (breakpoint === 'sm') { - this.columns = 2; - } else { - this.columns = 1; - } - }, - }, - mounted() { - this.scrollHandlerWrapper = this.scrollHandler.bind(this); - this.setColumnCountWrapper = this.setColumnCount.bind(this); - this.setColumnCount(); - - this.$refs.list.addEventListener('scroll', this.scrollHandlerWrapper); - window.addEventListener('resize', this.setColumnCountWrapper); - }, - beforeDestroy() { - this.$refs.list.removeEventListener('scroll', this.scrollHandlerWrapper); - window.removeEventListener('resize', this.setColumnCountWrapper); - }, - components: { - 'issue-card-inner': gl.issueBoards.IssueCardInner, - }, - template: ` -
-
-
-
-
-

- There are no issues to show. -

-
-
-
-
-
- - - - - -
-
-
-
- `, - }); -})(); diff --git a/app/assets/javascripts/boards/components/modal/lists_dropdown.js b/app/assets/javascripts/boards/components/modal/lists_dropdown.js new file mode 100644 index 00000000000..3c05120a2da --- /dev/null +++ b/app/assets/javascripts/boards/components/modal/lists_dropdown.js @@ -0,0 +1,56 @@ +/* global Vue */ +(() => { + const ModalStore = gl.issueBoards.ModalStore; + + gl.issueBoards.ModalFooterListsDropdown = Vue.extend({ + data() { + return { + modal: ModalStore.store, + state: gl.issueBoards.BoardsStore.state, + }; + }, + computed: { + selected() { + return this.modal.selectedList || this.state.lists[0]; + }, + }, + destroyed() { + this.modal.selectedList = null; + }, + template: ` + + `, + }); +})(); diff --git a/app/assets/javascripts/boards/components/modal/lists_dropdown.js.es6 b/app/assets/javascripts/boards/components/modal/lists_dropdown.js.es6 deleted file mode 100644 index 3c05120a2da..00000000000 --- a/app/assets/javascripts/boards/components/modal/lists_dropdown.js.es6 +++ /dev/null @@ -1,56 +0,0 @@ -/* global Vue */ -(() => { - const ModalStore = gl.issueBoards.ModalStore; - - gl.issueBoards.ModalFooterListsDropdown = Vue.extend({ - data() { - return { - modal: ModalStore.store, - state: gl.issueBoards.BoardsStore.state, - }; - }, - computed: { - selected() { - return this.modal.selectedList || this.state.lists[0]; - }, - }, - destroyed() { - this.modal.selectedList = null; - }, - template: ` - - `, - }); -})(); diff --git a/app/assets/javascripts/boards/components/modal/tabs.js b/app/assets/javascripts/boards/components/modal/tabs.js new file mode 100644 index 00000000000..e8cb43f3503 --- /dev/null +++ b/app/assets/javascripts/boards/components/modal/tabs.js @@ -0,0 +1,47 @@ +/* global Vue */ +(() => { + const ModalStore = gl.issueBoards.ModalStore; + + gl.issueBoards.ModalTabs = Vue.extend({ + mixins: [gl.issueBoards.ModalMixins], + data() { + return ModalStore.store; + }, + computed: { + selectedCount() { + return ModalStore.selectedCount(); + }, + }, + destroyed() { + this.activeTab = 'all'; + }, + template: ` + + `, + }); +})(); diff --git a/app/assets/javascripts/boards/components/modal/tabs.js.es6 b/app/assets/javascripts/boards/components/modal/tabs.js.es6 deleted file mode 100644 index e8cb43f3503..00000000000 --- a/app/assets/javascripts/boards/components/modal/tabs.js.es6 +++ /dev/null @@ -1,47 +0,0 @@ -/* global Vue */ -(() => { - const ModalStore = gl.issueBoards.ModalStore; - - gl.issueBoards.ModalTabs = Vue.extend({ - mixins: [gl.issueBoards.ModalMixins], - data() { - return ModalStore.store; - }, - computed: { - selectedCount() { - return ModalStore.selectedCount(); - }, - }, - destroyed() { - this.activeTab = 'all'; - }, - template: ` - - `, - }); -})(); diff --git a/app/assets/javascripts/boards/components/new_list_dropdown.js b/app/assets/javascripts/boards/components/new_list_dropdown.js new file mode 100644 index 00000000000..556826a9148 --- /dev/null +++ b/app/assets/javascripts/boards/components/new_list_dropdown.js @@ -0,0 +1,76 @@ +/* eslint-disable comma-dangle, func-names, no-new, space-before-function-paren, one-var */ + +(() => { + window.gl = window.gl || {}; + window.gl.issueBoards = window.gl.issueBoards || {}; + + const Store = gl.issueBoards.BoardsStore; + + $(document).off('created.label').on('created.label', (e, label) => { + Store.new({ + title: label.title, + position: Store.state.lists.length - 2, + list_type: 'label', + label: { + id: label.id, + title: label.title, + color: label.color + } + }); + }); + + gl.issueBoards.newListDropdownInit = () => { + $('.js-new-board-list').each(function () { + const $this = $(this); + new gl.CreateLabelDropdown($this.closest('.dropdown').find('.dropdown-new-label'), $this.data('namespace-path'), $this.data('project-path')); + + $this.glDropdown({ + data(term, callback) { + $.get($this.attr('data-labels')) + .then((resp) => { + callback(resp); + }); + }, + renderRow (label) { + const active = Store.findList('title', label.title); + const $li = $('
  • '); + const $a = $('', { + class: (active ? `is-active js-board-list-${active.id}` : ''), + text: label.title, + href: '#' + }); + const $labelColor = $('', { + class: 'dropdown-label-box', + style: `background-color: ${label.color}` + }); + + return $li.append($a.prepend($labelColor)); + }, + search: { + fields: ['title'] + }, + filterable: true, + selectable: true, + multiSelect: true, + clicked (label, $el, e) { + e.preventDefault(); + + if (!Store.findList('title', label.title)) { + Store.new({ + title: label.title, + position: Store.state.lists.length - 2, + list_type: 'label', + label: { + id: label.id, + title: label.title, + color: label.color + } + }); + + Store.state.lists = _.sortBy(Store.state.lists, 'position'); + } + } + }); + }); + }; +})(); diff --git a/app/assets/javascripts/boards/components/new_list_dropdown.js.es6 b/app/assets/javascripts/boards/components/new_list_dropdown.js.es6 deleted file mode 100644 index 556826a9148..00000000000 --- a/app/assets/javascripts/boards/components/new_list_dropdown.js.es6 +++ /dev/null @@ -1,76 +0,0 @@ -/* eslint-disable comma-dangle, func-names, no-new, space-before-function-paren, one-var */ - -(() => { - window.gl = window.gl || {}; - window.gl.issueBoards = window.gl.issueBoards || {}; - - const Store = gl.issueBoards.BoardsStore; - - $(document).off('created.label').on('created.label', (e, label) => { - Store.new({ - title: label.title, - position: Store.state.lists.length - 2, - list_type: 'label', - label: { - id: label.id, - title: label.title, - color: label.color - } - }); - }); - - gl.issueBoards.newListDropdownInit = () => { - $('.js-new-board-list').each(function () { - const $this = $(this); - new gl.CreateLabelDropdown($this.closest('.dropdown').find('.dropdown-new-label'), $this.data('namespace-path'), $this.data('project-path')); - - $this.glDropdown({ - data(term, callback) { - $.get($this.attr('data-labels')) - .then((resp) => { - callback(resp); - }); - }, - renderRow (label) { - const active = Store.findList('title', label.title); - const $li = $('
  • '); - const $a = $('', { - class: (active ? `is-active js-board-list-${active.id}` : ''), - text: label.title, - href: '#' - }); - const $labelColor = $('', { - class: 'dropdown-label-box', - style: `background-color: ${label.color}` - }); - - return $li.append($a.prepend($labelColor)); - }, - search: { - fields: ['title'] - }, - filterable: true, - selectable: true, - multiSelect: true, - clicked (label, $el, e) { - e.preventDefault(); - - if (!Store.findList('title', label.title)) { - Store.new({ - title: label.title, - position: Store.state.lists.length - 2, - list_type: 'label', - label: { - id: label.id, - title: label.title, - color: label.color - } - }); - - Store.state.lists = _.sortBy(Store.state.lists, 'position'); - } - } - }); - }); - }; -})(); diff --git a/app/assets/javascripts/boards/components/sidebar/remove_issue.js b/app/assets/javascripts/boards/components/sidebar/remove_issue.js new file mode 100644 index 00000000000..e74935e1cb0 --- /dev/null +++ b/app/assets/javascripts/boards/components/sidebar/remove_issue.js @@ -0,0 +1,59 @@ +/* eslint-disable no-new */ +/* global Vue */ +/* global Flash */ +(() => { + const Store = gl.issueBoards.BoardsStore; + + window.gl = window.gl || {}; + window.gl.issueBoards = window.gl.issueBoards || {}; + + gl.issueBoards.RemoveIssueBtn = Vue.extend({ + props: { + issue: { + type: Object, + required: true, + }, + list: { + type: Object, + required: true, + }, + }, + methods: { + removeIssue() { + const issue = this.issue; + const lists = issue.getLists(); + const labelIds = lists.map(list => list.label.id); + + // Post the remove data + gl.boardService.bulkUpdate([issue.globalId], { + remove_label_ids: labelIds, + }).catch(() => { + new Flash('Failed to remove issue from board, please try again.', 'alert'); + + lists.forEach((list) => { + list.addIssue(issue); + }); + }); + + // Remove from the frontend store + lists.forEach((list) => { + list.removeIssue(issue); + }); + + Store.detail.issue = {}; + }, + }, + template: ` +
    + +
    + `, + }); +})(); diff --git a/app/assets/javascripts/boards/components/sidebar/remove_issue.js.es6 b/app/assets/javascripts/boards/components/sidebar/remove_issue.js.es6 deleted file mode 100644 index e74935e1cb0..00000000000 --- a/app/assets/javascripts/boards/components/sidebar/remove_issue.js.es6 +++ /dev/null @@ -1,59 +0,0 @@ -/* eslint-disable no-new */ -/* global Vue */ -/* global Flash */ -(() => { - const Store = gl.issueBoards.BoardsStore; - - window.gl = window.gl || {}; - window.gl.issueBoards = window.gl.issueBoards || {}; - - gl.issueBoards.RemoveIssueBtn = Vue.extend({ - props: { - issue: { - type: Object, - required: true, - }, - list: { - type: Object, - required: true, - }, - }, - methods: { - removeIssue() { - const issue = this.issue; - const lists = issue.getLists(); - const labelIds = lists.map(list => list.label.id); - - // Post the remove data - gl.boardService.bulkUpdate([issue.globalId], { - remove_label_ids: labelIds, - }).catch(() => { - new Flash('Failed to remove issue from board, please try again.', 'alert'); - - lists.forEach((list) => { - list.addIssue(issue); - }); - }); - - // Remove from the frontend store - lists.forEach((list) => { - list.removeIssue(issue); - }); - - Store.detail.issue = {}; - }, - }, - template: ` -
    - -
    - `, - }); -})(); diff --git a/app/assets/javascripts/boards/filters/due_date_filters.js b/app/assets/javascripts/boards/filters/due_date_filters.js new file mode 100644 index 00000000000..03425bb145b --- /dev/null +++ b/app/assets/javascripts/boards/filters/due_date_filters.js @@ -0,0 +1,7 @@ +/* global Vue */ +/* global dateFormat */ + +Vue.filter('due-date', (value) => { + const date = new Date(value); + return dateFormat(date, 'mmm d, yyyy', true); +}); diff --git a/app/assets/javascripts/boards/filters/due_date_filters.js.es6 b/app/assets/javascripts/boards/filters/due_date_filters.js.es6 deleted file mode 100644 index 03425bb145b..00000000000 --- a/app/assets/javascripts/boards/filters/due_date_filters.js.es6 +++ /dev/null @@ -1,7 +0,0 @@ -/* global Vue */ -/* global dateFormat */ - -Vue.filter('due-date', (value) => { - const date = new Date(value); - return dateFormat(date, 'mmm d, yyyy', true); -}); diff --git a/app/assets/javascripts/boards/mixins/modal_mixins.js b/app/assets/javascripts/boards/mixins/modal_mixins.js new file mode 100644 index 00000000000..d378b7d4baf --- /dev/null +++ b/app/assets/javascripts/boards/mixins/modal_mixins.js @@ -0,0 +1,14 @@ +(() => { + const ModalStore = gl.issueBoards.ModalStore; + + gl.issueBoards.ModalMixins = { + methods: { + toggleModal(toggle) { + ModalStore.store.showAddIssuesModal = toggle; + }, + changeTab(tab) { + ModalStore.store.activeTab = tab; + }, + }, + }; +})(); diff --git a/app/assets/javascripts/boards/mixins/modal_mixins.js.es6 b/app/assets/javascripts/boards/mixins/modal_mixins.js.es6 deleted file mode 100644 index d378b7d4baf..00000000000 --- a/app/assets/javascripts/boards/mixins/modal_mixins.js.es6 +++ /dev/null @@ -1,14 +0,0 @@ -(() => { - const ModalStore = gl.issueBoards.ModalStore; - - gl.issueBoards.ModalMixins = { - methods: { - toggleModal(toggle) { - ModalStore.store.showAddIssuesModal = toggle; - }, - changeTab(tab) { - ModalStore.store.activeTab = tab; - }, - }, - }; -})(); diff --git a/app/assets/javascripts/boards/mixins/sortable_default_options.js b/app/assets/javascripts/boards/mixins/sortable_default_options.js new file mode 100644 index 00000000000..b6c6d17274f --- /dev/null +++ b/app/assets/javascripts/boards/mixins/sortable_default_options.js @@ -0,0 +1,39 @@ +/* eslint-disable no-unused-vars, no-mixed-operators, comma-dangle */ +/* global DocumentTouch */ + +((w) => { + window.gl = window.gl || {}; + window.gl.issueBoards = window.gl.issueBoards || {}; + + gl.issueBoards.onStart = () => { + $('.has-tooltip').tooltip('hide') + .tooltip('disable'); + document.body.classList.add('is-dragging'); + }; + + gl.issueBoards.onEnd = () => { + $('.has-tooltip').tooltip('enable'); + document.body.classList.remove('is-dragging'); + }; + + gl.issueBoards.touchEnabled = ('ontouchstart' in window) || window.DocumentTouch && document instanceof DocumentTouch; + + gl.issueBoards.getBoardSortableDefaultOptions = (obj) => { + const defaultSortOptions = { + animation: 200, + forceFallback: true, + fallbackClass: 'is-dragging', + fallbackOnBody: true, + ghostClass: 'is-ghost', + filter: '.board-delete, .btn', + delay: gl.issueBoards.touchEnabled ? 100 : 0, + scrollSensitivity: gl.issueBoards.touchEnabled ? 60 : 100, + scrollSpeed: 20, + onStart: gl.issueBoards.onStart, + onEnd: gl.issueBoards.onEnd + }; + + Object.keys(obj).forEach((key) => { defaultSortOptions[key] = obj[key]; }); + return defaultSortOptions; + }; +})(window); diff --git a/app/assets/javascripts/boards/mixins/sortable_default_options.js.es6 b/app/assets/javascripts/boards/mixins/sortable_default_options.js.es6 deleted file mode 100644 index b6c6d17274f..00000000000 --- a/app/assets/javascripts/boards/mixins/sortable_default_options.js.es6 +++ /dev/null @@ -1,39 +0,0 @@ -/* eslint-disable no-unused-vars, no-mixed-operators, comma-dangle */ -/* global DocumentTouch */ - -((w) => { - window.gl = window.gl || {}; - window.gl.issueBoards = window.gl.issueBoards || {}; - - gl.issueBoards.onStart = () => { - $('.has-tooltip').tooltip('hide') - .tooltip('disable'); - document.body.classList.add('is-dragging'); - }; - - gl.issueBoards.onEnd = () => { - $('.has-tooltip').tooltip('enable'); - document.body.classList.remove('is-dragging'); - }; - - gl.issueBoards.touchEnabled = ('ontouchstart' in window) || window.DocumentTouch && document instanceof DocumentTouch; - - gl.issueBoards.getBoardSortableDefaultOptions = (obj) => { - const defaultSortOptions = { - animation: 200, - forceFallback: true, - fallbackClass: 'is-dragging', - fallbackOnBody: true, - ghostClass: 'is-ghost', - filter: '.board-delete, .btn', - delay: gl.issueBoards.touchEnabled ? 100 : 0, - scrollSensitivity: gl.issueBoards.touchEnabled ? 60 : 100, - scrollSpeed: 20, - onStart: gl.issueBoards.onStart, - onEnd: gl.issueBoards.onEnd - }; - - Object.keys(obj).forEach((key) => { defaultSortOptions[key] = obj[key]; }); - return defaultSortOptions; - }; -})(window); diff --git a/app/assets/javascripts/boards/models/issue.js b/app/assets/javascripts/boards/models/issue.js new file mode 100644 index 00000000000..2d0a295ae4d --- /dev/null +++ b/app/assets/javascripts/boards/models/issue.js @@ -0,0 +1,78 @@ +/* eslint-disable no-unused-vars, space-before-function-paren, arrow-body-style, arrow-parens, comma-dangle, max-len */ +/* global Vue */ +/* global ListLabel */ +/* global ListMilestone */ +/* global ListUser */ + +class ListIssue { + constructor (obj) { + this.globalId = obj.id; + this.id = obj.iid; + this.title = obj.title; + this.confidential = obj.confidential; + this.dueDate = obj.due_date; + this.subscribed = obj.subscribed; + this.labels = []; + this.selected = false; + this.assignee = false; + + if (obj.assignee) { + this.assignee = new ListUser(obj.assignee); + } + + if (obj.milestone) { + this.milestone = new ListMilestone(obj.milestone); + } + + obj.labels.forEach((label) => { + this.labels.push(new ListLabel(label)); + }); + + this.priority = this.labels.reduce((max, label) => { + return (label.priority < max) ? label.priority : max; + }, Infinity); + } + + addLabel (label) { + if (!this.findLabel(label)) { + this.labels.push(new ListLabel(label)); + } + } + + findLabel (findLabel) { + return this.labels.filter(label => label.title === findLabel.title)[0]; + } + + removeLabel (removeLabel) { + if (removeLabel) { + this.labels = this.labels.filter(label => removeLabel.title !== label.title); + } + } + + removeLabels (labels) { + labels.forEach(this.removeLabel.bind(this)); + } + + getLists () { + return gl.issueBoards.BoardsStore.state.lists.filter(list => list.findIssue(this.id)); + } + + update (url) { + const data = { + issue: { + milestone_id: this.milestone ? this.milestone.id : null, + due_date: this.dueDate, + assignee_id: this.assignee ? this.assignee.id : null, + label_ids: this.labels.map((label) => label.id) + } + }; + + if (!data.issue.label_ids.length) { + data.issue.label_ids = ['']; + } + + return Vue.http.patch(url, data); + } +} + +window.ListIssue = ListIssue; diff --git a/app/assets/javascripts/boards/models/issue.js.es6 b/app/assets/javascripts/boards/models/issue.js.es6 deleted file mode 100644 index 2d0a295ae4d..00000000000 --- a/app/assets/javascripts/boards/models/issue.js.es6 +++ /dev/null @@ -1,78 +0,0 @@ -/* eslint-disable no-unused-vars, space-before-function-paren, arrow-body-style, arrow-parens, comma-dangle, max-len */ -/* global Vue */ -/* global ListLabel */ -/* global ListMilestone */ -/* global ListUser */ - -class ListIssue { - constructor (obj) { - this.globalId = obj.id; - this.id = obj.iid; - this.title = obj.title; - this.confidential = obj.confidential; - this.dueDate = obj.due_date; - this.subscribed = obj.subscribed; - this.labels = []; - this.selected = false; - this.assignee = false; - - if (obj.assignee) { - this.assignee = new ListUser(obj.assignee); - } - - if (obj.milestone) { - this.milestone = new ListMilestone(obj.milestone); - } - - obj.labels.forEach((label) => { - this.labels.push(new ListLabel(label)); - }); - - this.priority = this.labels.reduce((max, label) => { - return (label.priority < max) ? label.priority : max; - }, Infinity); - } - - addLabel (label) { - if (!this.findLabel(label)) { - this.labels.push(new ListLabel(label)); - } - } - - findLabel (findLabel) { - return this.labels.filter(label => label.title === findLabel.title)[0]; - } - - removeLabel (removeLabel) { - if (removeLabel) { - this.labels = this.labels.filter(label => removeLabel.title !== label.title); - } - } - - removeLabels (labels) { - labels.forEach(this.removeLabel.bind(this)); - } - - getLists () { - return gl.issueBoards.BoardsStore.state.lists.filter(list => list.findIssue(this.id)); - } - - update (url) { - const data = { - issue: { - milestone_id: this.milestone ? this.milestone.id : null, - due_date: this.dueDate, - assignee_id: this.assignee ? this.assignee.id : null, - label_ids: this.labels.map((label) => label.id) - } - }; - - if (!data.issue.label_ids.length) { - data.issue.label_ids = ['']; - } - - return Vue.http.patch(url, data); - } -} - -window.ListIssue = ListIssue; diff --git a/app/assets/javascripts/boards/models/label.js b/app/assets/javascripts/boards/models/label.js new file mode 100644 index 00000000000..9af88d167d6 --- /dev/null +++ b/app/assets/javascripts/boards/models/label.js @@ -0,0 +1,14 @@ +/* eslint-disable no-unused-vars, space-before-function-paren */ + +class ListLabel { + constructor (obj) { + this.id = obj.id; + this.title = obj.title; + this.color = obj.color; + this.textColor = obj.text_color; + this.description = obj.description; + this.priority = (obj.priority !== null) ? obj.priority : Infinity; + } +} + +window.ListLabel = ListLabel; diff --git a/app/assets/javascripts/boards/models/label.js.es6 b/app/assets/javascripts/boards/models/label.js.es6 deleted file mode 100644 index 9af88d167d6..00000000000 --- a/app/assets/javascripts/boards/models/label.js.es6 +++ /dev/null @@ -1,14 +0,0 @@ -/* eslint-disable no-unused-vars, space-before-function-paren */ - -class ListLabel { - constructor (obj) { - this.id = obj.id; - this.title = obj.title; - this.color = obj.color; - this.textColor = obj.text_color; - this.description = obj.description; - this.priority = (obj.priority !== null) ? obj.priority : Infinity; - } -} - -window.ListLabel = ListLabel; diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js new file mode 100644 index 00000000000..8158ed4ec2c --- /dev/null +++ b/app/assets/javascripts/boards/models/list.js @@ -0,0 +1,156 @@ +/* eslint-disable space-before-function-paren, no-underscore-dangle, class-methods-use-this, consistent-return, no-shadow, no-param-reassign, max-len, no-unused-vars */ +/* global ListIssue */ +/* global ListLabel */ + +class List { + constructor (obj) { + this.id = obj.id; + this._uid = this.guid(); + this.position = obj.position; + 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.page = 1; + this.loading = true; + this.loadingMore = false; + this.issues = []; + this.issuesSize = 0; + + if (obj.label) { + this.label = new ListLabel(obj.label); + } + + if (this.type !== 'blank' && this.id) { + this.getIssues(); + } + } + + guid() { + const s4 = () => Math.floor((1 + Math.random()) * 0x10000).toString(16).substring(1); + return `${s4()}${s4()}-${s4()}-${s4()}-${s4()}-${s4()}${s4()}${s4()}`; + } + + save () { + return gl.boardService.createList(this.label.id) + .then((resp) => { + const data = resp.json(); + + this.id = data.id; + this.type = data.list_type; + this.position = data.position; + + return this.getIssues(); + }); + } + + destroy () { + const index = gl.issueBoards.BoardsStore.state.lists.indexOf(this); + gl.issueBoards.BoardsStore.state.lists.splice(index, 1); + gl.issueBoards.BoardsStore.updateNewListDropdown(this.id); + + gl.boardService.destroyList(this.id); + } + + update () { + gl.boardService.updateList(this.id, this.position); + } + + nextPage () { + if (this.issuesSize > this.issues.length) { + this.page += 1; + + return this.getIssues(false); + } + } + + getIssues (emptyIssues = true) { + const filters = this.filters; + const data = { page: this.page }; + + Object.keys(filters).forEach((key) => { data[key] = filters[key]; }); + + if (this.label) { + data.label_name = data.label_name.filter(label => label !== this.label.title); + } + + if (emptyIssues) { + this.loading = true; + } + + return gl.boardService.getIssuesForList(this.id, data) + .then((resp) => { + const data = resp.json(); + this.loading = false; + this.issuesSize = data.size; + + if (emptyIssues) { + this.issues = []; + } + + this.createIssues(data.issues); + }); + } + + newIssue (issue) { + this.addIssue(issue); + this.issuesSize += 1; + + return gl.boardService.newIssue(this.id, issue) + .then((resp) => { + const data = resp.json(); + issue.id = data.iid; + }); + } + + createIssues (data) { + data.forEach((issueObj) => { + this.addIssue(new ListIssue(issueObj)); + }); + } + + addIssue (issue, listFrom, newIndex) { + if (!this.findIssue(issue.id)) { + if (newIndex !== undefined) { + this.issues.splice(newIndex, 0, issue); + } else { + this.issues.push(issue); + } + + if (this.label) { + issue.addLabel(this.label); + } + + if (listFrom) { + this.issuesSize += 1; + this.updateIssueLabel(issue, listFrom); + } + } + } + + updateIssueLabel(issue, listFrom) { + gl.boardService.moveIssue(issue.id, listFrom.id, this.id) + .then(() => { + listFrom.getIssues(false); + }); + } + + findIssue (id) { + return this.issues.filter(issue => issue.id === id)[0]; + } + + removeIssue (removeIssue) { + this.issues = this.issues.filter((issue) => { + const matchesRemove = removeIssue.id === issue.id; + + if (matchesRemove) { + this.issuesSize -= 1; + issue.removeLabel(this.label); + } + + return !matchesRemove; + }); + } +} + +window.List = List; diff --git a/app/assets/javascripts/boards/models/list.js.es6 b/app/assets/javascripts/boards/models/list.js.es6 deleted file mode 100644 index 8158ed4ec2c..00000000000 --- a/app/assets/javascripts/boards/models/list.js.es6 +++ /dev/null @@ -1,156 +0,0 @@ -/* eslint-disable space-before-function-paren, no-underscore-dangle, class-methods-use-this, consistent-return, no-shadow, no-param-reassign, max-len, no-unused-vars */ -/* global ListIssue */ -/* global ListLabel */ - -class List { - constructor (obj) { - this.id = obj.id; - this._uid = this.guid(); - this.position = obj.position; - 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.page = 1; - this.loading = true; - this.loadingMore = false; - this.issues = []; - this.issuesSize = 0; - - if (obj.label) { - this.label = new ListLabel(obj.label); - } - - if (this.type !== 'blank' && this.id) { - this.getIssues(); - } - } - - guid() { - const s4 = () => Math.floor((1 + Math.random()) * 0x10000).toString(16).substring(1); - return `${s4()}${s4()}-${s4()}-${s4()}-${s4()}-${s4()}${s4()}${s4()}`; - } - - save () { - return gl.boardService.createList(this.label.id) - .then((resp) => { - const data = resp.json(); - - this.id = data.id; - this.type = data.list_type; - this.position = data.position; - - return this.getIssues(); - }); - } - - destroy () { - const index = gl.issueBoards.BoardsStore.state.lists.indexOf(this); - gl.issueBoards.BoardsStore.state.lists.splice(index, 1); - gl.issueBoards.BoardsStore.updateNewListDropdown(this.id); - - gl.boardService.destroyList(this.id); - } - - update () { - gl.boardService.updateList(this.id, this.position); - } - - nextPage () { - if (this.issuesSize > this.issues.length) { - this.page += 1; - - return this.getIssues(false); - } - } - - getIssues (emptyIssues = true) { - const filters = this.filters; - const data = { page: this.page }; - - Object.keys(filters).forEach((key) => { data[key] = filters[key]; }); - - if (this.label) { - data.label_name = data.label_name.filter(label => label !== this.label.title); - } - - if (emptyIssues) { - this.loading = true; - } - - return gl.boardService.getIssuesForList(this.id, data) - .then((resp) => { - const data = resp.json(); - this.loading = false; - this.issuesSize = data.size; - - if (emptyIssues) { - this.issues = []; - } - - this.createIssues(data.issues); - }); - } - - newIssue (issue) { - this.addIssue(issue); - this.issuesSize += 1; - - return gl.boardService.newIssue(this.id, issue) - .then((resp) => { - const data = resp.json(); - issue.id = data.iid; - }); - } - - createIssues (data) { - data.forEach((issueObj) => { - this.addIssue(new ListIssue(issueObj)); - }); - } - - addIssue (issue, listFrom, newIndex) { - if (!this.findIssue(issue.id)) { - if (newIndex !== undefined) { - this.issues.splice(newIndex, 0, issue); - } else { - this.issues.push(issue); - } - - if (this.label) { - issue.addLabel(this.label); - } - - if (listFrom) { - this.issuesSize += 1; - this.updateIssueLabel(issue, listFrom); - } - } - } - - updateIssueLabel(issue, listFrom) { - gl.boardService.moveIssue(issue.id, listFrom.id, this.id) - .then(() => { - listFrom.getIssues(false); - }); - } - - findIssue (id) { - return this.issues.filter(issue => issue.id === id)[0]; - } - - removeIssue (removeIssue) { - this.issues = this.issues.filter((issue) => { - const matchesRemove = removeIssue.id === issue.id; - - if (matchesRemove) { - this.issuesSize -= 1; - issue.removeLabel(this.label); - } - - return !matchesRemove; - }); - } -} - -window.List = List; diff --git a/app/assets/javascripts/boards/models/milestone.js b/app/assets/javascripts/boards/models/milestone.js new file mode 100644 index 00000000000..c867b06d320 --- /dev/null +++ b/app/assets/javascripts/boards/models/milestone.js @@ -0,0 +1,10 @@ +/* eslint-disable no-unused-vars */ + +class ListMilestone { + constructor(obj) { + this.id = obj.id; + this.title = obj.title; + } +} + +window.ListMilestone = ListMilestone; diff --git a/app/assets/javascripts/boards/models/milestone.js.es6 b/app/assets/javascripts/boards/models/milestone.js.es6 deleted file mode 100644 index c867b06d320..00000000000 --- a/app/assets/javascripts/boards/models/milestone.js.es6 +++ /dev/null @@ -1,10 +0,0 @@ -/* eslint-disable no-unused-vars */ - -class ListMilestone { - constructor(obj) { - this.id = obj.id; - this.title = obj.title; - } -} - -window.ListMilestone = ListMilestone; diff --git a/app/assets/javascripts/boards/models/user.js b/app/assets/javascripts/boards/models/user.js new file mode 100644 index 00000000000..8e9de4d4cbb --- /dev/null +++ b/app/assets/javascripts/boards/models/user.js @@ -0,0 +1,12 @@ +/* eslint-disable no-unused-vars */ + +class ListUser { + constructor(user) { + this.id = user.id; + this.name = user.name; + this.username = user.username; + this.avatar = user.avatar_url; + } +} + +window.ListUser = ListUser; diff --git a/app/assets/javascripts/boards/models/user.js.es6 b/app/assets/javascripts/boards/models/user.js.es6 deleted file mode 100644 index 8e9de4d4cbb..00000000000 --- a/app/assets/javascripts/boards/models/user.js.es6 +++ /dev/null @@ -1,12 +0,0 @@ -/* eslint-disable no-unused-vars */ - -class ListUser { - constructor(user) { - this.id = user.id; - this.name = user.name; - this.username = user.username; - this.avatar = user.avatar_url; - } -} - -window.ListUser = ListUser; diff --git a/app/assets/javascripts/boards/services/board_service.js b/app/assets/javascripts/boards/services/board_service.js new file mode 100644 index 00000000000..065e90518df --- /dev/null +++ b/app/assets/javascripts/boards/services/board_service.js @@ -0,0 +1,95 @@ +/* eslint-disable space-before-function-paren, comma-dangle, no-param-reassign, camelcase, max-len, no-unused-vars */ +/* global Vue */ + +class BoardService { + constructor (root, bulkUpdatePath, boardId) { + this.boards = Vue.resource(`${root}{/id}.json`, {}, { + issues: { + method: 'GET', + url: `${root}/${boardId}/issues.json` + } + }); + this.lists = Vue.resource(`${root}/${boardId}/lists{/id}`, {}, { + generate: { + method: 'POST', + url: `${root}/${boardId}/lists/generate.json` + } + }); + this.issue = Vue.resource(`${root}/${boardId}/issues{/id}`, {}); + this.issues = Vue.resource(`${root}/${boardId}/lists{/id}/issues`, {}, { + bulkUpdate: { + method: 'POST', + url: bulkUpdatePath, + }, + }); + + Vue.http.interceptors.push((request, next) => { + request.headers['X-CSRF-Token'] = $.rails.csrfToken(); + next(); + }); + } + + all () { + return this.lists.get(); + } + + generateDefaultLists () { + return this.lists.generate({}); + } + + createList (label_id) { + return this.lists.save({}, { + list: { + label_id + } + }); + } + + updateList (id, position) { + return this.lists.update({ id }, { + list: { + position + } + }); + } + + destroyList (id) { + return this.lists.delete({ id }); + } + + getIssuesForList (id, filter = {}) { + const data = { id }; + Object.keys(filter).forEach((key) => { data[key] = filter[key]; }); + + return this.issues.get(data); + } + + moveIssue (id, from_list_id, to_list_id) { + return this.issue.update({ id }, { + from_list_id, + to_list_id + }); + } + + newIssue (id, issue) { + return this.issues.save({ id }, { + issue + }); + } + + getBacklog(data) { + return this.boards.issues(data); + } + + bulkUpdate(issueIds, extraData = {}) { + const data = { + update: Object.assign(extraData, { + issuable_ids: issueIds.join(','), + }), + }; + + return this.issues.bulkUpdate(data); + } +} + +window.BoardService = BoardService; diff --git a/app/assets/javascripts/boards/services/board_service.js.es6 b/app/assets/javascripts/boards/services/board_service.js.es6 deleted file mode 100644 index 065e90518df..00000000000 --- a/app/assets/javascripts/boards/services/board_service.js.es6 +++ /dev/null @@ -1,95 +0,0 @@ -/* eslint-disable space-before-function-paren, comma-dangle, no-param-reassign, camelcase, max-len, no-unused-vars */ -/* global Vue */ - -class BoardService { - constructor (root, bulkUpdatePath, boardId) { - this.boards = Vue.resource(`${root}{/id}.json`, {}, { - issues: { - method: 'GET', - url: `${root}/${boardId}/issues.json` - } - }); - this.lists = Vue.resource(`${root}/${boardId}/lists{/id}`, {}, { - generate: { - method: 'POST', - url: `${root}/${boardId}/lists/generate.json` - } - }); - this.issue = Vue.resource(`${root}/${boardId}/issues{/id}`, {}); - this.issues = Vue.resource(`${root}/${boardId}/lists{/id}/issues`, {}, { - bulkUpdate: { - method: 'POST', - url: bulkUpdatePath, - }, - }); - - Vue.http.interceptors.push((request, next) => { - request.headers['X-CSRF-Token'] = $.rails.csrfToken(); - next(); - }); - } - - all () { - return this.lists.get(); - } - - generateDefaultLists () { - return this.lists.generate({}); - } - - createList (label_id) { - return this.lists.save({}, { - list: { - label_id - } - }); - } - - updateList (id, position) { - return this.lists.update({ id }, { - list: { - position - } - }); - } - - destroyList (id) { - return this.lists.delete({ id }); - } - - getIssuesForList (id, filter = {}) { - const data = { id }; - Object.keys(filter).forEach((key) => { data[key] = filter[key]; }); - - return this.issues.get(data); - } - - moveIssue (id, from_list_id, to_list_id) { - return this.issue.update({ id }, { - from_list_id, - to_list_id - }); - } - - newIssue (id, issue) { - return this.issues.save({ id }, { - issue - }); - } - - getBacklog(data) { - return this.boards.issues(data); - } - - bulkUpdate(issueIds, extraData = {}) { - const data = { - update: Object.assign(extraData, { - issuable_ids: issueIds.join(','), - }), - }; - - return this.issues.bulkUpdate(data); - } -} - -window.BoardService = BoardService; diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js new file mode 100644 index 00000000000..56436c8fdc7 --- /dev/null +++ b/app/assets/javascripts/boards/stores/boards_store.js @@ -0,0 +1,123 @@ +/* eslint-disable comma-dangle, space-before-function-paren, one-var, no-shadow, dot-notation, max-len */ +/* global Cookies */ +/* global List */ + +(() => { + window.gl = window.gl || {}; + window.gl.issueBoards = window.gl.issueBoards || {}; + + gl.issueBoards.BoardsStore = { + disabled: false, + state: {}, + detail: { + issue: {} + }, + moving: { + issue: {}, + list: {} + }, + create () { + this.state.lists = []; + this.state.filters = { + author_id: gl.utils.getParameterValues('author_id')[0], + assignee_id: gl.utils.getParameterValues('assignee_id')[0], + milestone_title: gl.utils.getParameterValues('milestone_title')[0], + label_name: gl.utils.getParameterValues('label_name[]'), + search: '' + }; + }, + addList (listObj) { + const list = new List(listObj); + this.state.lists.push(list); + + return list; + }, + new (listObj) { + const list = this.addList(listObj); + + list + .save() + .then(() => { + this.state.lists = _.sortBy(this.state.lists, 'position'); + }); + this.removeBlankState(); + }, + updateNewListDropdown (listId) { + $(`.js-board-list-${listId}`).removeClass('is-active'); + }, + shouldAddBlankState () { + // Decide whether to add the blank state + return !(this.state.lists.filter(list => list.type !== 'done')[0]); + }, + addBlankState () { + if (!this.shouldAddBlankState() || this.welcomeIsHidden() || this.disabled) return; + + this.addList({ + id: 'blank', + list_type: 'blank', + title: 'Welcome to your Issue Board!', + position: 0 + }); + + this.state.lists = _.sortBy(this.state.lists, 'position'); + }, + removeBlankState () { + this.removeList('blank'); + + Cookies.set('issue_board_welcome_hidden', 'true', { + expires: 365 * 10, + path: '' + }); + }, + welcomeIsHidden () { + return Cookies.get('issue_board_welcome_hidden') === 'true'; + }, + removeList (id, type = 'blank') { + const list = this.findList('id', id, type); + + if (!list) return; + + this.state.lists = this.state.lists.filter(list => list.id !== id); + }, + moveList (listFrom, orderLists) { + orderLists.forEach((id, i) => { + const list = this.findList('id', parseInt(id, 10)); + + list.position = i; + }); + listFrom.update(); + }, + moveIssueToList (listFrom, listTo, issue, newIndex) { + const issueTo = listTo.findIssue(issue.id); + const issueLists = issue.getLists(); + const listLabels = issueLists.map(listIssue => listIssue.label); + + if (!issueTo) { + // Add to new lists issues if it doesn't already exist + listTo.addIssue(issue, listFrom, newIndex); + } else { + listTo.updateIssueLabel(issue, listFrom); + issueTo.removeLabel(listFrom.label); + } + + if (listTo.type === 'done') { + issueLists.forEach((list) => { + list.removeIssue(issue); + }); + issue.removeLabels(listLabels); + } else { + listFrom.removeIssue(issue); + } + }, + findList (key, val, type = 'label') { + return this.state.lists.filter((list) => { + const byType = type ? list['type'] === type : true; + + return list[key] === val && byType; + })[0]; + }, + updateFiltersUrl () { + history.pushState(null, null, `?${$.param(this.state.filters)}`); + } + }; +})(); diff --git a/app/assets/javascripts/boards/stores/boards_store.js.es6 b/app/assets/javascripts/boards/stores/boards_store.js.es6 deleted file mode 100644 index 56436c8fdc7..00000000000 --- a/app/assets/javascripts/boards/stores/boards_store.js.es6 +++ /dev/null @@ -1,123 +0,0 @@ -/* eslint-disable comma-dangle, space-before-function-paren, one-var, no-shadow, dot-notation, max-len */ -/* global Cookies */ -/* global List */ - -(() => { - window.gl = window.gl || {}; - window.gl.issueBoards = window.gl.issueBoards || {}; - - gl.issueBoards.BoardsStore = { - disabled: false, - state: {}, - detail: { - issue: {} - }, - moving: { - issue: {}, - list: {} - }, - create () { - this.state.lists = []; - this.state.filters = { - author_id: gl.utils.getParameterValues('author_id')[0], - assignee_id: gl.utils.getParameterValues('assignee_id')[0], - milestone_title: gl.utils.getParameterValues('milestone_title')[0], - label_name: gl.utils.getParameterValues('label_name[]'), - search: '' - }; - }, - addList (listObj) { - const list = new List(listObj); - this.state.lists.push(list); - - return list; - }, - new (listObj) { - const list = this.addList(listObj); - - list - .save() - .then(() => { - this.state.lists = _.sortBy(this.state.lists, 'position'); - }); - this.removeBlankState(); - }, - updateNewListDropdown (listId) { - $(`.js-board-list-${listId}`).removeClass('is-active'); - }, - shouldAddBlankState () { - // Decide whether to add the blank state - return !(this.state.lists.filter(list => list.type !== 'done')[0]); - }, - addBlankState () { - if (!this.shouldAddBlankState() || this.welcomeIsHidden() || this.disabled) return; - - this.addList({ - id: 'blank', - list_type: 'blank', - title: 'Welcome to your Issue Board!', - position: 0 - }); - - this.state.lists = _.sortBy(this.state.lists, 'position'); - }, - removeBlankState () { - this.removeList('blank'); - - Cookies.set('issue_board_welcome_hidden', 'true', { - expires: 365 * 10, - path: '' - }); - }, - welcomeIsHidden () { - return Cookies.get('issue_board_welcome_hidden') === 'true'; - }, - removeList (id, type = 'blank') { - const list = this.findList('id', id, type); - - if (!list) return; - - this.state.lists = this.state.lists.filter(list => list.id !== id); - }, - moveList (listFrom, orderLists) { - orderLists.forEach((id, i) => { - const list = this.findList('id', parseInt(id, 10)); - - list.position = i; - }); - listFrom.update(); - }, - moveIssueToList (listFrom, listTo, issue, newIndex) { - const issueTo = listTo.findIssue(issue.id); - const issueLists = issue.getLists(); - const listLabels = issueLists.map(listIssue => listIssue.label); - - if (!issueTo) { - // Add to new lists issues if it doesn't already exist - listTo.addIssue(issue, listFrom, newIndex); - } else { - listTo.updateIssueLabel(issue, listFrom); - issueTo.removeLabel(listFrom.label); - } - - if (listTo.type === 'done') { - issueLists.forEach((list) => { - list.removeIssue(issue); - }); - issue.removeLabels(listLabels); - } else { - listFrom.removeIssue(issue); - } - }, - findList (key, val, type = 'label') { - return this.state.lists.filter((list) => { - const byType = type ? list['type'] === type : true; - - return list[key] === val && byType; - })[0]; - }, - updateFiltersUrl () { - history.pushState(null, null, `?${$.param(this.state.filters)}`); - } - }; -})(); diff --git a/app/assets/javascripts/boards/stores/modal_store.js b/app/assets/javascripts/boards/stores/modal_store.js new file mode 100644 index 00000000000..15fc6c79e8d --- /dev/null +++ b/app/assets/javascripts/boards/stores/modal_store.js @@ -0,0 +1,107 @@ +(() => { + window.gl = window.gl || {}; + window.gl.issueBoards = window.gl.issueBoards || {}; + + class ModalStore { + constructor() { + this.store = { + columns: 3, + issues: [], + issuesCount: false, + selectedIssues: [], + showAddIssuesModal: false, + activeTab: 'all', + selectedList: null, + searchTerm: '', + loading: false, + loadingNewPage: false, + page: 1, + perPage: 50, + }; + + this.setDefaultFilter(); + } + + setDefaultFilter() { + this.store.filter = { + author_id: '', + assignee_id: '', + milestone_title: '', + label_name: [], + }; + } + + selectedCount() { + return this.getSelectedIssues().length; + } + + toggleIssue(issueObj) { + const issue = issueObj; + const selected = issue.selected; + + issue.selected = !selected; + + if (!selected) { + this.addSelectedIssue(issue); + } else { + this.removeSelectedIssue(issue); + } + } + + toggleAll() { + const select = this.selectedCount() !== this.store.issues.length; + + this.store.issues.forEach((issue) => { + const issueUpdate = issue; + + if (issueUpdate.selected !== select) { + issueUpdate.selected = select; + + if (select) { + this.addSelectedIssue(issue); + } else { + this.removeSelectedIssue(issue); + } + } + }); + } + + getSelectedIssues() { + return this.store.selectedIssues.filter(issue => issue.selected); + } + + addSelectedIssue(issue) { + const index = this.selectedIssueIndex(issue); + + if (index === -1) { + this.store.selectedIssues.push(issue); + } + } + + removeSelectedIssue(issue, forcePurge = false) { + if (this.store.activeTab === 'all' || forcePurge) { + this.store.selectedIssues = this.store.selectedIssues + .filter(fIssue => fIssue.id !== issue.id); + } + } + + purgeUnselectedIssues() { + this.store.selectedIssues.forEach((issue) => { + if (!issue.selected) { + this.removeSelectedIssue(issue, true); + } + }); + } + + selectedIssueIndex(issue) { + return this.store.selectedIssues.indexOf(issue); + } + + findSelectedIssue(issue) { + return this.store.selectedIssues + .filter(filteredIssue => filteredIssue.id === issue.id)[0]; + } + } + + gl.issueBoards.ModalStore = new ModalStore(); +})(); diff --git a/app/assets/javascripts/boards/stores/modal_store.js.es6 b/app/assets/javascripts/boards/stores/modal_store.js.es6 deleted file mode 100644 index 15fc6c79e8d..00000000000 --- a/app/assets/javascripts/boards/stores/modal_store.js.es6 +++ /dev/null @@ -1,107 +0,0 @@ -(() => { - window.gl = window.gl || {}; - window.gl.issueBoards = window.gl.issueBoards || {}; - - class ModalStore { - constructor() { - this.store = { - columns: 3, - issues: [], - issuesCount: false, - selectedIssues: [], - showAddIssuesModal: false, - activeTab: 'all', - selectedList: null, - searchTerm: '', - loading: false, - loadingNewPage: false, - page: 1, - perPage: 50, - }; - - this.setDefaultFilter(); - } - - setDefaultFilter() { - this.store.filter = { - author_id: '', - assignee_id: '', - milestone_title: '', - label_name: [], - }; - } - - selectedCount() { - return this.getSelectedIssues().length; - } - - toggleIssue(issueObj) { - const issue = issueObj; - const selected = issue.selected; - - issue.selected = !selected; - - if (!selected) { - this.addSelectedIssue(issue); - } else { - this.removeSelectedIssue(issue); - } - } - - toggleAll() { - const select = this.selectedCount() !== this.store.issues.length; - - this.store.issues.forEach((issue) => { - const issueUpdate = issue; - - if (issueUpdate.selected !== select) { - issueUpdate.selected = select; - - if (select) { - this.addSelectedIssue(issue); - } else { - this.removeSelectedIssue(issue); - } - } - }); - } - - getSelectedIssues() { - return this.store.selectedIssues.filter(issue => issue.selected); - } - - addSelectedIssue(issue) { - const index = this.selectedIssueIndex(issue); - - if (index === -1) { - this.store.selectedIssues.push(issue); - } - } - - removeSelectedIssue(issue, forcePurge = false) { - if (this.store.activeTab === 'all' || forcePurge) { - this.store.selectedIssues = this.store.selectedIssues - .filter(fIssue => fIssue.id !== issue.id); - } - } - - purgeUnselectedIssues() { - this.store.selectedIssues.forEach((issue) => { - if (!issue.selected) { - this.removeSelectedIssue(issue, true); - } - }); - } - - selectedIssueIndex(issue) { - return this.store.selectedIssues.indexOf(issue); - } - - findSelectedIssue(issue) { - return this.store.selectedIssues - .filter(filteredIssue => filteredIssue.id === issue.id)[0]; - } - } - - gl.issueBoards.ModalStore = new ModalStore(); -})(); diff --git a/app/assets/javascripts/build_variables.js b/app/assets/javascripts/build_variables.js new file mode 100644 index 00000000000..99082b412e2 --- /dev/null +++ b/app/assets/javascripts/build_variables.js @@ -0,0 +1,8 @@ +/* eslint-disable func-names, prefer-arrow-callback, space-before-function-paren */ + +$(function() { + $('.reveal-variables').off('click').on('click', function() { + $('.js-build').toggle().niceScroll(); + $(this).hide(); + }); +}); diff --git a/app/assets/javascripts/build_variables.js.es6 b/app/assets/javascripts/build_variables.js.es6 deleted file mode 100644 index 99082b412e2..00000000000 --- a/app/assets/javascripts/build_variables.js.es6 +++ /dev/null @@ -1,8 +0,0 @@ -/* eslint-disable func-names, prefer-arrow-callback, space-before-function-paren */ - -$(function() { - $('.reveal-variables').off('click').on('click', function() { - $('.js-build').toggle().niceScroll(); - $(this).hide(); - }); -}); diff --git a/app/assets/javascripts/ci_lint_editor.js b/app/assets/javascripts/ci_lint_editor.js new file mode 100644 index 00000000000..56ffaa765a8 --- /dev/null +++ b/app/assets/javascripts/ci_lint_editor.js @@ -0,0 +1,18 @@ +(() => { + window.gl = window.gl || {}; + + 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; +})(); diff --git a/app/assets/javascripts/ci_lint_editor.js.es6 b/app/assets/javascripts/ci_lint_editor.js.es6 deleted file mode 100644 index 56ffaa765a8..00000000000 --- a/app/assets/javascripts/ci_lint_editor.js.es6 +++ /dev/null @@ -1,18 +0,0 @@ -(() => { - window.gl = window.gl || {}; - - 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; -})(); diff --git a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js new file mode 100644 index 00000000000..b5a988df897 --- /dev/null +++ b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js @@ -0,0 +1,29 @@ +/* eslint-disable no-new, no-param-reassign */ +/* global Vue, CommitsPipelineStore, PipelinesService, Flash */ + +window.Vue = require('vue'); +require('./pipelines_table'); +/** + * Commits View > Pipelines Tab > Pipelines Table. + * Merge Request View > Pipelines Tab > Pipelines Table. + * + * Renders Pipelines table in pipelines tab in the commits show view. + * Renders Pipelines table in pipelines tab in the merge request show view. + */ + +$(() => { + window.gl = window.gl || {}; + gl.commits = gl.commits || {}; + gl.commits.pipelines = gl.commits.pipelines || {}; + + if (gl.commits.PipelinesTableBundle) { + gl.commits.PipelinesTableBundle.$destroy(true); + } + + const pipelineTableViewEl = document.querySelector('#commit-pipeline-table-view'); + gl.commits.pipelines.PipelinesTableBundle = new gl.commits.pipelines.PipelinesTableView(); + + if (pipelineTableViewEl && pipelineTableViewEl.dataset.disableInitialization === undefined) { + gl.commits.pipelines.PipelinesTableBundle.$mount(pipelineTableViewEl); + } +}); diff --git a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js.es6 b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js.es6 deleted file mode 100644 index b5a988df897..00000000000 --- a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js.es6 +++ /dev/null @@ -1,29 +0,0 @@ -/* eslint-disable no-new, no-param-reassign */ -/* global Vue, CommitsPipelineStore, PipelinesService, Flash */ - -window.Vue = require('vue'); -require('./pipelines_table'); -/** - * Commits View > Pipelines Tab > Pipelines Table. - * Merge Request View > Pipelines Tab > Pipelines Table. - * - * Renders Pipelines table in pipelines tab in the commits show view. - * Renders Pipelines table in pipelines tab in the merge request show view. - */ - -$(() => { - window.gl = window.gl || {}; - gl.commits = gl.commits || {}; - gl.commits.pipelines = gl.commits.pipelines || {}; - - if (gl.commits.PipelinesTableBundle) { - gl.commits.PipelinesTableBundle.$destroy(true); - } - - const pipelineTableViewEl = document.querySelector('#commit-pipeline-table-view'); - gl.commits.pipelines.PipelinesTableBundle = new gl.commits.pipelines.PipelinesTableView(); - - if (pipelineTableViewEl && pipelineTableViewEl.dataset.disableInitialization === undefined) { - gl.commits.pipelines.PipelinesTableBundle.$mount(pipelineTableViewEl); - } -}); diff --git a/app/assets/javascripts/commit/pipelines/pipelines_service.js b/app/assets/javascripts/commit/pipelines/pipelines_service.js new file mode 100644 index 00000000000..8ae98f9bf97 --- /dev/null +++ b/app/assets/javascripts/commit/pipelines/pipelines_service.js @@ -0,0 +1,44 @@ +/* globals Vue */ +/* eslint-disable no-unused-vars, no-param-reassign */ + +/** + * Pipelines service. + * + * Used to fetch the data used to render the pipelines table. + * Uses Vue.Resource + */ +class PipelinesService { + + /** + * FIXME: The url provided to request the pipelines in the new merge request + * page already has `.json`. + * This should be fixed when the endpoint is improved. + * + * @param {String} root + */ + constructor(root) { + let endpoint; + + if (root.indexOf('.json') === -1) { + endpoint = `${root}.json`; + } else { + endpoint = root; + } + this.pipelines = Vue.resource(endpoint); + } + + /** + * Given the root param provided when the class is initialized, will + * make a GET request. + * + * @return {Promise} + */ + all() { + return this.pipelines.get(); + } +} + +window.gl = window.gl || {}; +gl.commits = gl.commits || {}; +gl.commits.pipelines = gl.commits.pipelines || {}; +gl.commits.pipelines.PipelinesService = PipelinesService; diff --git a/app/assets/javascripts/commit/pipelines/pipelines_service.js.es6 b/app/assets/javascripts/commit/pipelines/pipelines_service.js.es6 deleted file mode 100644 index 8ae98f9bf97..00000000000 --- a/app/assets/javascripts/commit/pipelines/pipelines_service.js.es6 +++ /dev/null @@ -1,44 +0,0 @@ -/* globals Vue */ -/* eslint-disable no-unused-vars, no-param-reassign */ - -/** - * Pipelines service. - * - * Used to fetch the data used to render the pipelines table. - * Uses Vue.Resource - */ -class PipelinesService { - - /** - * FIXME: The url provided to request the pipelines in the new merge request - * page already has `.json`. - * This should be fixed when the endpoint is improved. - * - * @param {String} root - */ - constructor(root) { - let endpoint; - - if (root.indexOf('.json') === -1) { - endpoint = `${root}.json`; - } else { - endpoint = root; - } - this.pipelines = Vue.resource(endpoint); - } - - /** - * Given the root param provided when the class is initialized, will - * make a GET request. - * - * @return {Promise} - */ - all() { - return this.pipelines.get(); - } -} - -window.gl = window.gl || {}; -gl.commits = gl.commits || {}; -gl.commits.pipelines = gl.commits.pipelines || {}; -gl.commits.pipelines.PipelinesService = PipelinesService; diff --git a/app/assets/javascripts/commit/pipelines/pipelines_store.js b/app/assets/javascripts/commit/pipelines/pipelines_store.js new file mode 100644 index 00000000000..f1b80e45444 --- /dev/null +++ b/app/assets/javascripts/commit/pipelines/pipelines_store.js @@ -0,0 +1,48 @@ +/* eslint-disable no-underscore-dangle*/ +/** + * Pipelines' Store for commits view. + * + * Used to store the Pipelines rendered in the commit view in the pipelines table. + */ +require('../../vue_realtime_listener'); + +class PipelinesStore { + constructor() { + this.state = {}; + this.state.pipelines = []; + } + + storePipelines(pipelines = []) { + this.state.pipelines = pipelines; + + return pipelines; + } + + /** + * Once the data is received we will start the time ago loops. + * + * Everytime a request is made like retry or cancel a pipeline, every 10 seconds we + * update the time to show how long as passed. + * + */ + static startTimeAgoLoops() { + const startTimeLoops = () => { + this.timeLoopInterval = setInterval(() => { + this.$children[0].$children.reduce((acc, component) => { + const timeAgoComponent = component.$children.filter(el => el.$options._componentTag === 'time-ago')[0]; + acc.push(timeAgoComponent); + return acc; + }, []).forEach(e => e.changeTime()); + }, 10000); + }; + + startTimeLoops(); + + const removeIntervals = () => clearInterval(this.timeLoopInterval); + const startIntervals = () => startTimeLoops(); + + gl.VueRealtimeListener(removeIntervals, startIntervals); + } +} + +module.exports = PipelinesStore; diff --git a/app/assets/javascripts/commit/pipelines/pipelines_store.js.es6 b/app/assets/javascripts/commit/pipelines/pipelines_store.js.es6 deleted file mode 100644 index f1b80e45444..00000000000 --- a/app/assets/javascripts/commit/pipelines/pipelines_store.js.es6 +++ /dev/null @@ -1,48 +0,0 @@ -/* eslint-disable no-underscore-dangle*/ -/** - * Pipelines' Store for commits view. - * - * Used to store the Pipelines rendered in the commit view in the pipelines table. - */ -require('../../vue_realtime_listener'); - -class PipelinesStore { - constructor() { - this.state = {}; - this.state.pipelines = []; - } - - storePipelines(pipelines = []) { - this.state.pipelines = pipelines; - - return pipelines; - } - - /** - * Once the data is received we will start the time ago loops. - * - * Everytime a request is made like retry or cancel a pipeline, every 10 seconds we - * update the time to show how long as passed. - * - */ - static startTimeAgoLoops() { - const startTimeLoops = () => { - this.timeLoopInterval = setInterval(() => { - this.$children[0].$children.reduce((acc, component) => { - const timeAgoComponent = component.$children.filter(el => el.$options._componentTag === 'time-ago')[0]; - acc.push(timeAgoComponent); - return acc; - }, []).forEach(e => e.changeTime()); - }, 10000); - }; - - startTimeLoops(); - - const removeIntervals = () => clearInterval(this.timeLoopInterval); - const startIntervals = () => startTimeLoops(); - - gl.VueRealtimeListener(removeIntervals, startIntervals); - } -} - -module.exports = PipelinesStore; diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.js b/app/assets/javascripts/commit/pipelines/pipelines_table.js new file mode 100644 index 00000000000..631ed34851c --- /dev/null +++ b/app/assets/javascripts/commit/pipelines/pipelines_table.js @@ -0,0 +1,104 @@ +/* eslint-disable no-new, no-param-reassign */ +/* global Vue, CommitsPipelineStore, PipelinesService, Flash */ + +window.Vue = require('vue'); +window.Vue.use(require('vue-resource')); +require('../../lib/utils/common_utils'); +require('../../vue_shared/vue_resource_interceptor'); +require('../../vue_shared/components/pipelines_table'); +require('./pipelines_service'); +const PipelineStore = require('./pipelines_store'); + +/** + * + * Uses `pipelines-table-component` to render Pipelines table with an API call. + * Endpoint is provided in HTML and passed as `endpoint`. + * We need a store to store the received environemnts. + * We need a service to communicate with the server. + * + * Necessary SVG in the table are provided as props. This should be refactored + * as soon as we have Webpack and can load them directly into JS files. + */ + +(() => { + window.gl = window.gl || {}; + gl.commits = gl.commits || {}; + gl.commits.pipelines = gl.commits.pipelines || {}; + + gl.commits.pipelines.PipelinesTableView = Vue.component('pipelines-table', { + + components: { + 'pipelines-table-component': gl.pipelines.PipelinesTableComponent, + }, + + /** + * Accesses the DOM to provide the needed data. + * Returns the necessary props to render `pipelines-table-component` component. + * + * @return {Object} + */ + data() { + const pipelinesTableData = document.querySelector('#commit-pipeline-table-view').dataset; + const store = new PipelineStore(); + + return { + endpoint: pipelinesTableData.endpoint, + store, + state: store.state, + isLoading: false, + }; + }, + + /** + * When the component is about to be mounted, tell the service to fetch the data + * + * A request to fetch the pipelines will be made. + * In case of a successfull response we will store the data in the provided + * store, in case of a failed response we need to warn the user. + * + */ + beforeMount() { + const pipelinesService = new gl.commits.pipelines.PipelinesService(this.endpoint); + + this.isLoading = true; + return pipelinesService.all() + .then(response => response.json()) + .then((json) => { + // depending of the endpoint the response can either bring a `pipelines` key or not. + const pipelines = json.pipelines || json; + this.store.storePipelines(pipelines); + this.isLoading = false; + }) + .catch(() => { + this.isLoading = false; + new Flash('An error occurred while fetching the pipelines, please reload the page again.', 'alert'); + }); + }, + + beforeUpdate() { + if (this.state.pipelines.length && this.$children) { + PipelineStore.startTimeAgoLoops.call(this, Vue); + } + }, + + template: ` +
    +
    + +
    + +
    +

    + No pipelines to show +

    +
    + +
    + +
    +
    + `, + }); +})(); diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.js.es6 b/app/assets/javascripts/commit/pipelines/pipelines_table.js.es6 deleted file mode 100644 index 631ed34851c..00000000000 --- a/app/assets/javascripts/commit/pipelines/pipelines_table.js.es6 +++ /dev/null @@ -1,104 +0,0 @@ -/* eslint-disable no-new, no-param-reassign */ -/* global Vue, CommitsPipelineStore, PipelinesService, Flash */ - -window.Vue = require('vue'); -window.Vue.use(require('vue-resource')); -require('../../lib/utils/common_utils'); -require('../../vue_shared/vue_resource_interceptor'); -require('../../vue_shared/components/pipelines_table'); -require('./pipelines_service'); -const PipelineStore = require('./pipelines_store'); - -/** - * - * Uses `pipelines-table-component` to render Pipelines table with an API call. - * Endpoint is provided in HTML and passed as `endpoint`. - * We need a store to store the received environemnts. - * We need a service to communicate with the server. - * - * Necessary SVG in the table are provided as props. This should be refactored - * as soon as we have Webpack and can load them directly into JS files. - */ - -(() => { - window.gl = window.gl || {}; - gl.commits = gl.commits || {}; - gl.commits.pipelines = gl.commits.pipelines || {}; - - gl.commits.pipelines.PipelinesTableView = Vue.component('pipelines-table', { - - components: { - 'pipelines-table-component': gl.pipelines.PipelinesTableComponent, - }, - - /** - * Accesses the DOM to provide the needed data. - * Returns the necessary props to render `pipelines-table-component` component. - * - * @return {Object} - */ - data() { - const pipelinesTableData = document.querySelector('#commit-pipeline-table-view').dataset; - const store = new PipelineStore(); - - return { - endpoint: pipelinesTableData.endpoint, - store, - state: store.state, - isLoading: false, - }; - }, - - /** - * When the component is about to be mounted, tell the service to fetch the data - * - * A request to fetch the pipelines will be made. - * In case of a successfull response we will store the data in the provided - * store, in case of a failed response we need to warn the user. - * - */ - beforeMount() { - const pipelinesService = new gl.commits.pipelines.PipelinesService(this.endpoint); - - this.isLoading = true; - return pipelinesService.all() - .then(response => response.json()) - .then((json) => { - // depending of the endpoint the response can either bring a `pipelines` key or not. - const pipelines = json.pipelines || json; - this.store.storePipelines(pipelines); - this.isLoading = false; - }) - .catch(() => { - this.isLoading = false; - new Flash('An error occurred while fetching the pipelines, please reload the page again.', 'alert'); - }); - }, - - beforeUpdate() { - if (this.state.pipelines.length && this.$children) { - PipelineStore.startTimeAgoLoops.call(this, Vue); - } - }, - - template: ` -
    -
    - -
    - -
    -

    - No pipelines to show -

    -
    - -
    - -
    -
    - `, - }); -})(); diff --git a/app/assets/javascripts/compare_autocomplete.js b/app/assets/javascripts/compare_autocomplete.js new file mode 100644 index 00000000000..1eca973e069 --- /dev/null +++ b/app/assets/javascripts/compare_autocomplete.js @@ -0,0 +1,69 @@ +/* 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(); + } + + 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); + } + }, + 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'); + } + }); + }); + }; + + return CompareAutocomplete; + })(); +}).call(window); diff --git a/app/assets/javascripts/compare_autocomplete.js.es6 b/app/assets/javascripts/compare_autocomplete.js.es6 deleted file mode 100644 index 1eca973e069..00000000000 --- a/app/assets/javascripts/compare_autocomplete.js.es6 +++ /dev/null @@ -1,69 +0,0 @@ -/* 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(); - } - - 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); - } - }, - 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'); - } - }); - }); - }; - - return CompareAutocomplete; - })(); -}).call(window); diff --git a/app/assets/javascripts/copy_as_gfm.js b/app/assets/javascripts/copy_as_gfm.js new file mode 100644 index 00000000000..2bc3d85fba4 --- /dev/null +++ b/app/assets/javascripts/copy_as_gfm.js @@ -0,0 +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'); + }, + }, + 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'(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}`; + }).join('\n'); + } + + 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; + } + + 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; + + // If the documentFragment contains more than just Markdown, don't copy as GFM. + if (documentFragment.querySelector('.md, .wiki')) return; + + e.preventDefault(); + clipboardData.setData('text/plain', documentFragment.textContent); + + const gfm = CopyAsGFM.nodeToGFM(documentFragment); + clipboardData.setData('text/x-gfm', gfm); + } + + handlePaste(e) { + const clipboardData = e.originalEvent.clipboardData; + if (!clipboardData) return; + + const gfm = clipboardData.getData('text/x-gfm'); + if (!gfm) return; + + e.preventDefault(); + + window.gl.utils.insertText(e.target, gfm); + } + + static nodeToGFM(node) { + if (node.nodeType === Node.TEXT_NODE) { + return node.textContent; + } + + const text = this.innerGFM(node); + + if (node.nodeType === Node.DOCUMENT_FRAGMENT_NODE) { + return text; + } + + for (const filter in gfmRules) { + const rules = gfmRules[filter]; + + for (const selector in rules) { + const func = rules[selector]; + + if (!window.gl.utils.nodeMatchesSelector(node, selector)) continue; + + const result = func(node, text); + if (result === false) continue; + + return result; + } + } + + return text; + } + + 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; + + new CopyAsGFM(); +})(); diff --git a/app/assets/javascripts/copy_as_gfm.js.es6 b/app/assets/javascripts/copy_as_gfm.js.es6 deleted file mode 100644 index 2bc3d85fba4..00000000000 --- a/app/assets/javascripts/copy_as_gfm.js.es6 +++ /dev/null @@ -1,361 +0,0 @@ -/* 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'); - }, - }, - 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'(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}`; - }).join('\n'); - } - - 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; - } - - 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; - - // If the documentFragment contains more than just Markdown, don't copy as GFM. - if (documentFragment.querySelector('.md, .wiki')) return; - - e.preventDefault(); - clipboardData.setData('text/plain', documentFragment.textContent); - - const gfm = CopyAsGFM.nodeToGFM(documentFragment); - clipboardData.setData('text/x-gfm', gfm); - } - - handlePaste(e) { - const clipboardData = e.originalEvent.clipboardData; - if (!clipboardData) return; - - const gfm = clipboardData.getData('text/x-gfm'); - if (!gfm) return; - - e.preventDefault(); - - window.gl.utils.insertText(e.target, gfm); - } - - static nodeToGFM(node) { - if (node.nodeType === Node.TEXT_NODE) { - return node.textContent; - } - - const text = this.innerGFM(node); - - if (node.nodeType === Node.DOCUMENT_FRAGMENT_NODE) { - return text; - } - - for (const filter in gfmRules) { - const rules = gfmRules[filter]; - - for (const selector in rules) { - const func = rules[selector]; - - if (!window.gl.utils.nodeMatchesSelector(node, selector)) continue; - - const result = func(node, text); - if (result === false) continue; - - return result; - } - } - - return text; - } - - 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; - - new CopyAsGFM(); -})(); diff --git a/app/assets/javascripts/create_label.js b/app/assets/javascripts/create_label.js new file mode 100644 index 00000000000..85384d98126 --- /dev/null +++ b/app/assets/javascripts/create_label.js @@ -0,0 +1,132 @@ +/* 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(); + + 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'); + } + + addBinding () { + const self = 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.$dropdownBack.on('click', this.resetForm.bind(this)); + + this.$cancelButton.on('click', function(e) { + e.preventDefault(); + e.stopPropagation(); + + self.resetForm(); + self.$dropdownBack.trigger('click'); + }); + + this.$newLabelCreateButton.on('click', this.saveLabel.bind(this)); + } + + 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'); + } + + enableLabelCreateButton () { + if (this.$newLabelField.val() !== '' && this.$newColorField.val() !== '') { + this.$newLabelError.hide(); + this.$newLabelCreateButton.enable(); + } else { + this.$newLabelCreateButton.disable(); + } + } + + resetForm () { + this.$newLabelField + .val('') + .trigger('change'); + + this.$newColorField + .val('') + .trigger('change'); + + this.$colorPreview + .css('background-color', '') + .parent() + .removeClass('is-active'); + } + + saveLabel (e) { + e.preventDefault(); + e.stopPropagation(); + + 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'); + + $(document).trigger('created.label', label); + } + }); + } + } + + if (!w.gl) { + w.gl = {}; + } + + gl.CreateLabelDropdown = CreateLabelDropdown; +})(window); diff --git a/app/assets/javascripts/create_label.js.es6 b/app/assets/javascripts/create_label.js.es6 deleted file mode 100644 index 85384d98126..00000000000 --- a/app/assets/javascripts/create_label.js.es6 +++ /dev/null @@ -1,132 +0,0 @@ -/* 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(); - - 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'); - } - - addBinding () { - const self = 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.$dropdownBack.on('click', this.resetForm.bind(this)); - - this.$cancelButton.on('click', function(e) { - e.preventDefault(); - e.stopPropagation(); - - self.resetForm(); - self.$dropdownBack.trigger('click'); - }); - - this.$newLabelCreateButton.on('click', this.saveLabel.bind(this)); - } - - 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'); - } - - enableLabelCreateButton () { - if (this.$newLabelField.val() !== '' && this.$newColorField.val() !== '') { - this.$newLabelError.hide(); - this.$newLabelCreateButton.enable(); - } else { - this.$newLabelCreateButton.disable(); - } - } - - resetForm () { - this.$newLabelField - .val('') - .trigger('change'); - - this.$newColorField - .val('') - .trigger('change'); - - this.$colorPreview - .css('background-color', '') - .parent() - .removeClass('is-active'); - } - - saveLabel (e) { - e.preventDefault(); - e.stopPropagation(); - - 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'); - - $(document).trigger('created.label', label); - } - }); - } - } - - if (!w.gl) { - w.gl = {}; - } - - gl.CreateLabelDropdown = CreateLabelDropdown; -})(window); diff --git a/app/assets/javascripts/cycle_analytics/components/stage_code_component.js b/app/assets/javascripts/cycle_analytics/components/stage_code_component.js new file mode 100644 index 00000000000..b83a4c63fad --- /dev/null +++ b/app/assets/javascripts/cycle_analytics/components/stage_code_component.js @@ -0,0 +1,45 @@ +/* eslint-disable no-param-reassign */ +/* global Vue */ + +((global) => { + global.cycleAnalytics = global.cycleAnalytics || {}; + + global.cycleAnalytics.StageCodeComponent = Vue.extend({ + props: { + items: Array, + stage: Object, + }, + template: ` +
    + `, + }); +})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/cycle_analytics/components/stage_code_component.js.es6 b/app/assets/javascripts/cycle_analytics/components/stage_code_component.js.es6 deleted file mode 100644 index b83a4c63fad..00000000000 --- a/app/assets/javascripts/cycle_analytics/components/stage_code_component.js.es6 +++ /dev/null @@ -1,45 +0,0 @@ -/* eslint-disable no-param-reassign */ -/* global Vue */ - -((global) => { - global.cycleAnalytics = global.cycleAnalytics || {}; - - global.cycleAnalytics.StageCodeComponent = Vue.extend({ - props: { - items: Array, - stage: Object, - }, - template: ` -
    -
    - {{ stage.description }} -
    - -
    - `, - }); -})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js b/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js new file mode 100644 index 00000000000..cb1687dcc7a --- /dev/null +++ b/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js @@ -0,0 +1,47 @@ +/* eslint-disable no-param-reassign */ +/* global Vue */ + +((global) => { + global.cycleAnalytics = global.cycleAnalytics || {}; + + global.cycleAnalytics.StageIssueComponent = Vue.extend({ + props: { + items: Array, + stage: Object, + }, + template: ` +
    +
    + {{ stage.description }} +
    + +
    + `, + }); +})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js.es6 b/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js.es6 deleted file mode 100644 index cb1687dcc7a..00000000000 --- a/app/assets/javascripts/cycle_analytics/components/stage_issue_component.js.es6 +++ /dev/null @@ -1,47 +0,0 @@ -/* eslint-disable no-param-reassign */ -/* global Vue */ - -((global) => { - global.cycleAnalytics = global.cycleAnalytics || {}; - - global.cycleAnalytics.StageIssueComponent = Vue.extend({ - props: { - items: Array, - stage: Object, - }, - template: ` -
    -
    - {{ stage.description }} -
    - -
    - `, - }); -})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js b/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js new file mode 100644 index 00000000000..42e1bbce744 --- /dev/null +++ b/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js @@ -0,0 +1,56 @@ +/* eslint-disable no-param-reassign */ +import Vue from 'vue'; +import iconCommit from '../svg/icon_commit.svg'; + +((global) => { + global.cycleAnalytics = global.cycleAnalytics || {}; + + global.cycleAnalytics.StagePlanComponent = Vue.extend({ + props: { + items: Array, + stage: Object, + }, + + data() { + return { iconCommit }; + }, + + template: ` +
    +
    + {{ stage.description }} + + + Showing 50 events + +
    + +
    + `, + }); +})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js.es6 b/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js.es6 deleted file mode 100644 index 42e1bbce744..00000000000 --- a/app/assets/javascripts/cycle_analytics/components/stage_plan_component.js.es6 +++ /dev/null @@ -1,56 +0,0 @@ -/* eslint-disable no-param-reassign */ -import Vue from 'vue'; -import iconCommit from '../svg/icon_commit.svg'; - -((global) => { - global.cycleAnalytics = global.cycleAnalytics || {}; - - global.cycleAnalytics.StagePlanComponent = Vue.extend({ - props: { - items: Array, - stage: Object, - }, - - data() { - return { iconCommit }; - }, - - template: ` -
    -
    - {{ stage.description }} - - - Showing 50 events - -
    - -
    - `, - }); -})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/cycle_analytics/components/stage_production_component.js b/app/assets/javascripts/cycle_analytics/components/stage_production_component.js new file mode 100644 index 00000000000..73f4205b578 --- /dev/null +++ b/app/assets/javascripts/cycle_analytics/components/stage_production_component.js @@ -0,0 +1,47 @@ +/* eslint-disable no-param-reassign */ +/* global Vue */ + +((global) => { + global.cycleAnalytics = global.cycleAnalytics || {}; + + global.cycleAnalytics.StageProductionComponent = Vue.extend({ + props: { + items: Array, + stage: Object, + }, + template: ` +
    +
    + {{ stage.description }} +
    + +
    + `, + }); +})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/cycle_analytics/components/stage_production_component.js.es6 b/app/assets/javascripts/cycle_analytics/components/stage_production_component.js.es6 deleted file mode 100644 index 73f4205b578..00000000000 --- a/app/assets/javascripts/cycle_analytics/components/stage_production_component.js.es6 +++ /dev/null @@ -1,47 +0,0 @@ -/* eslint-disable no-param-reassign */ -/* global Vue */ - -((global) => { - global.cycleAnalytics = global.cycleAnalytics || {}; - - global.cycleAnalytics.StageProductionComponent = Vue.extend({ - props: { - items: Array, - stage: Object, - }, - template: ` -
    -
    - {{ stage.description }} -
    - -
    - `, - }); -})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/cycle_analytics/components/stage_review_component.js b/app/assets/javascripts/cycle_analytics/components/stage_review_component.js new file mode 100644 index 00000000000..501ffb1fac9 --- /dev/null +++ b/app/assets/javascripts/cycle_analytics/components/stage_review_component.js @@ -0,0 +1,57 @@ +/* eslint-disable no-param-reassign */ +/* global Vue */ + +((global) => { + global.cycleAnalytics = global.cycleAnalytics || {}; + + global.cycleAnalytics.StageReviewComponent = Vue.extend({ + props: { + items: Array, + stage: Object, + }, + template: ` +
    +
    + {{ stage.description }} +
    + +
    + `, + }); +})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/cycle_analytics/components/stage_review_component.js.es6 b/app/assets/javascripts/cycle_analytics/components/stage_review_component.js.es6 deleted file mode 100644 index 501ffb1fac9..00000000000 --- a/app/assets/javascripts/cycle_analytics/components/stage_review_component.js.es6 +++ /dev/null @@ -1,57 +0,0 @@ -/* eslint-disable no-param-reassign */ -/* global Vue */ - -((global) => { - global.cycleAnalytics = global.cycleAnalytics || {}; - - global.cycleAnalytics.StageReviewComponent = Vue.extend({ - props: { - items: Array, - stage: Object, - }, - template: ` -
    -
    - {{ stage.description }} -
    - -
    - `, - }); -})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js b/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js new file mode 100644 index 00000000000..8fa63734cf1 --- /dev/null +++ b/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js @@ -0,0 +1,48 @@ +/* eslint-disable no-param-reassign */ +import Vue from 'vue'; +import iconBranch from '../svg/icon_branch.svg'; + +((global) => { + global.cycleAnalytics = global.cycleAnalytics || {}; + + global.cycleAnalytics.StageStagingComponent = Vue.extend({ + props: { + items: Array, + stage: Object, + }, + data() { + return { iconBranch }; + }, + template: ` +
    +
    + {{ stage.description }} +
    + +
    + `, + }); +})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js.es6 b/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js.es6 deleted file mode 100644 index 8fa63734cf1..00000000000 --- a/app/assets/javascripts/cycle_analytics/components/stage_staging_component.js.es6 +++ /dev/null @@ -1,48 +0,0 @@ -/* eslint-disable no-param-reassign */ -import Vue from 'vue'; -import iconBranch from '../svg/icon_branch.svg'; - -((global) => { - global.cycleAnalytics = global.cycleAnalytics || {}; - - global.cycleAnalytics.StageStagingComponent = Vue.extend({ - props: { - items: Array, - stage: Object, - }, - data() { - return { iconBranch }; - }, - template: ` -
    -
    - {{ stage.description }} -
    - -
    - `, - }); -})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/cycle_analytics/components/stage_test_component.js b/app/assets/javascripts/cycle_analytics/components/stage_test_component.js new file mode 100644 index 00000000000..0015249cfaa --- /dev/null +++ b/app/assets/javascripts/cycle_analytics/components/stage_test_component.js @@ -0,0 +1,49 @@ +/* eslint-disable no-param-reassign */ +import Vue from 'vue'; +import iconBuildStatus from '../svg/icon_build_status.svg'; +import iconBranch from '../svg/icon_branch.svg'; + +((global) => { + global.cycleAnalytics = global.cycleAnalytics || {}; + + global.cycleAnalytics.StageTestComponent = Vue.extend({ + props: { + items: Array, + stage: Object, + }, + data() { + return { iconBuildStatus, iconBranch }; + }, + template: ` +
    +
    + {{ stage.description }} +
    + +
    + `, + }); +})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/cycle_analytics/components/stage_test_component.js.es6 b/app/assets/javascripts/cycle_analytics/components/stage_test_component.js.es6 deleted file mode 100644 index 0015249cfaa..00000000000 --- a/app/assets/javascripts/cycle_analytics/components/stage_test_component.js.es6 +++ /dev/null @@ -1,49 +0,0 @@ -/* eslint-disable no-param-reassign */ -import Vue from 'vue'; -import iconBuildStatus from '../svg/icon_build_status.svg'; -import iconBranch from '../svg/icon_branch.svg'; - -((global) => { - global.cycleAnalytics = global.cycleAnalytics || {}; - - global.cycleAnalytics.StageTestComponent = Vue.extend({ - props: { - items: Array, - stage: Object, - }, - data() { - return { iconBuildStatus, iconBranch }; - }, - template: ` -
    -
    - {{ stage.description }} -
    - -
    - `, - }); -})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/cycle_analytics/components/total_time_component.js b/app/assets/javascripts/cycle_analytics/components/total_time_component.js new file mode 100644 index 00000000000..0d85e1a4678 --- /dev/null +++ b/app/assets/javascripts/cycle_analytics/components/total_time_component.js @@ -0,0 +1,25 @@ +/* eslint-disable no-param-reassign */ +/* global Vue */ + +((global) => { + global.cycleAnalytics = global.cycleAnalytics || {}; + + global.cycleAnalytics.TotalTimeComponent = Vue.extend({ + props: { + time: Object, + }, + template: ` + + + + + `, + }); +})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/cycle_analytics/components/total_time_component.js.es6 b/app/assets/javascripts/cycle_analytics/components/total_time_component.js.es6 deleted file mode 100644 index 0d85e1a4678..00000000000 --- a/app/assets/javascripts/cycle_analytics/components/total_time_component.js.es6 +++ /dev/null @@ -1,25 +0,0 @@ -/* eslint-disable no-param-reassign */ -/* global Vue */ - -((global) => { - global.cycleAnalytics = global.cycleAnalytics || {}; - - global.cycleAnalytics.TotalTimeComponent = Vue.extend({ - props: { - time: Object, - }, - template: ` - - - - - `, - }); -})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js new file mode 100644 index 00000000000..beff293b587 --- /dev/null +++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js @@ -0,0 +1,135 @@ +/* global Vue */ +/* global Cookies */ +/* global Flash */ + +window.Vue = require('vue'); +window.Cookies = require('js-cookie'); +require('./components/stage_code_component'); +require('./components/stage_issue_component'); +require('./components/stage_plan_component'); +require('./components/stage_production_component'); +require('./components/stage_review_component'); +require('./components/stage_staging_component'); +require('./components/stage_test_component'); +require('./components/total_time_component'); +require('./cycle_analytics_service'); +require('./cycle_analytics_store'); +require('./default_event_objects'); + +$(() => { + const OVERVIEW_DIALOG_COOKIE = 'cycle_analytics_help_dismissed'; + const cycleAnalyticsEl = document.querySelector('#cycle-analytics'); + const cycleAnalyticsStore = gl.cycleAnalytics.CycleAnalyticsStore; + const cycleAnalyticsService = new gl.cycleAnalytics.CycleAnalyticsService({ + requestPath: cycleAnalyticsEl.dataset.requestPath, + }); + + gl.cycleAnalyticsApp = new Vue({ + el: '#cycle-analytics', + name: 'CycleAnalytics', + data: { + state: cycleAnalyticsStore.state, + isLoading: false, + isLoadingStage: false, + isEmptyStage: false, + hasError: false, + startDate: 30, + isOverviewDialogDismissed: Cookies.get(OVERVIEW_DIALOG_COOKIE), + }, + computed: { + currentStage() { + return cycleAnalyticsStore.currentActiveStage(); + }, + }, + components: { + 'stage-issue-component': gl.cycleAnalytics.StageIssueComponent, + 'stage-plan-component': gl.cycleAnalytics.StagePlanComponent, + 'stage-code-component': gl.cycleAnalytics.StageCodeComponent, + 'stage-test-component': gl.cycleAnalytics.StageTestComponent, + 'stage-review-component': gl.cycleAnalytics.StageReviewComponent, + 'stage-staging-component': gl.cycleAnalytics.StageStagingComponent, + 'stage-production-component': gl.cycleAnalytics.StageProductionComponent, + }, + created() { + this.fetchCycleAnalyticsData(); + }, + methods: { + handleError() { + cycleAnalyticsStore.setErrorState(true); + return new Flash('There was an error while fetching cycle analytics data.'); + }, + initDropdown() { + const $dropdown = $('.js-ca-dropdown'); + const $label = $dropdown.find('.dropdown-label'); + + $dropdown.find('li a').off('click').on('click', (e) => { + e.preventDefault(); + const $target = $(e.currentTarget); + this.startDate = $target.data('value'); + + $label.text($target.text().trim()); + this.fetchCycleAnalyticsData({ startDate: this.startDate }); + }); + }, + fetchCycleAnalyticsData(options) { + const fetchOptions = options || { startDate: this.startDate }; + + this.isLoading = true; + + cycleAnalyticsService + .fetchCycleAnalyticsData(fetchOptions) + .done((response) => { + cycleAnalyticsStore.setCycleAnalyticsData(response); + this.selectDefaultStage(); + this.initDropdown(); + }) + .error(() => { + this.handleError(); + }) + .always(() => { + this.isLoading = false; + }); + }, + selectDefaultStage() { + const stage = this.state.stages.first(); + this.selectStage(stage); + }, + selectStage(stage) { + if (this.isLoadingStage) return; + if (this.currentStage === stage) return; + + if (!stage.isUserAllowed) { + cycleAnalyticsStore.setActiveStage(stage); + return; + } + + this.isLoadingStage = true; + cycleAnalyticsStore.setStageEvents([], stage); + cycleAnalyticsStore.setActiveStage(stage); + + cycleAnalyticsService + .fetchStageData({ + stage, + startDate: this.startDate, + }) + .done((response) => { + this.isEmptyStage = !response.events.length; + cycleAnalyticsStore.setStageEvents(response.events, stage); + }) + .error(() => { + this.isEmptyStage = true; + }) + .always(() => { + this.isLoadingStage = false; + }); + }, + dismissOverviewDialog() { + this.isOverviewDialogDismissed = true; + Cookies.set(OVERVIEW_DIALOG_COOKIE, '1'); + }, + }, + }); + + // Register global components + Vue.component('total-time', gl.cycleAnalytics.TotalTimeComponent); +}); diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js.es6 b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js.es6 deleted file mode 100644 index beff293b587..00000000000 --- a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js.es6 +++ /dev/null @@ -1,135 +0,0 @@ -/* global Vue */ -/* global Cookies */ -/* global Flash */ - -window.Vue = require('vue'); -window.Cookies = require('js-cookie'); -require('./components/stage_code_component'); -require('./components/stage_issue_component'); -require('./components/stage_plan_component'); -require('./components/stage_production_component'); -require('./components/stage_review_component'); -require('./components/stage_staging_component'); -require('./components/stage_test_component'); -require('./components/total_time_component'); -require('./cycle_analytics_service'); -require('./cycle_analytics_store'); -require('./default_event_objects'); - -$(() => { - const OVERVIEW_DIALOG_COOKIE = 'cycle_analytics_help_dismissed'; - const cycleAnalyticsEl = document.querySelector('#cycle-analytics'); - const cycleAnalyticsStore = gl.cycleAnalytics.CycleAnalyticsStore; - const cycleAnalyticsService = new gl.cycleAnalytics.CycleAnalyticsService({ - requestPath: cycleAnalyticsEl.dataset.requestPath, - }); - - gl.cycleAnalyticsApp = new Vue({ - el: '#cycle-analytics', - name: 'CycleAnalytics', - data: { - state: cycleAnalyticsStore.state, - isLoading: false, - isLoadingStage: false, - isEmptyStage: false, - hasError: false, - startDate: 30, - isOverviewDialogDismissed: Cookies.get(OVERVIEW_DIALOG_COOKIE), - }, - computed: { - currentStage() { - return cycleAnalyticsStore.currentActiveStage(); - }, - }, - components: { - 'stage-issue-component': gl.cycleAnalytics.StageIssueComponent, - 'stage-plan-component': gl.cycleAnalytics.StagePlanComponent, - 'stage-code-component': gl.cycleAnalytics.StageCodeComponent, - 'stage-test-component': gl.cycleAnalytics.StageTestComponent, - 'stage-review-component': gl.cycleAnalytics.StageReviewComponent, - 'stage-staging-component': gl.cycleAnalytics.StageStagingComponent, - 'stage-production-component': gl.cycleAnalytics.StageProductionComponent, - }, - created() { - this.fetchCycleAnalyticsData(); - }, - methods: { - handleError() { - cycleAnalyticsStore.setErrorState(true); - return new Flash('There was an error while fetching cycle analytics data.'); - }, - initDropdown() { - const $dropdown = $('.js-ca-dropdown'); - const $label = $dropdown.find('.dropdown-label'); - - $dropdown.find('li a').off('click').on('click', (e) => { - e.preventDefault(); - const $target = $(e.currentTarget); - this.startDate = $target.data('value'); - - $label.text($target.text().trim()); - this.fetchCycleAnalyticsData({ startDate: this.startDate }); - }); - }, - fetchCycleAnalyticsData(options) { - const fetchOptions = options || { startDate: this.startDate }; - - this.isLoading = true; - - cycleAnalyticsService - .fetchCycleAnalyticsData(fetchOptions) - .done((response) => { - cycleAnalyticsStore.setCycleAnalyticsData(response); - this.selectDefaultStage(); - this.initDropdown(); - }) - .error(() => { - this.handleError(); - }) - .always(() => { - this.isLoading = false; - }); - }, - selectDefaultStage() { - const stage = this.state.stages.first(); - this.selectStage(stage); - }, - selectStage(stage) { - if (this.isLoadingStage) return; - if (this.currentStage === stage) return; - - if (!stage.isUserAllowed) { - cycleAnalyticsStore.setActiveStage(stage); - return; - } - - this.isLoadingStage = true; - cycleAnalyticsStore.setStageEvents([], stage); - cycleAnalyticsStore.setActiveStage(stage); - - cycleAnalyticsService - .fetchStageData({ - stage, - startDate: this.startDate, - }) - .done((response) => { - this.isEmptyStage = !response.events.length; - cycleAnalyticsStore.setStageEvents(response.events, stage); - }) - .error(() => { - this.isEmptyStage = true; - }) - .always(() => { - this.isLoadingStage = false; - }); - }, - dismissOverviewDialog() { - this.isOverviewDialogDismissed = true; - Cookies.set(OVERVIEW_DIALOG_COOKIE, '1'); - }, - }, - }); - - // Register global components - Vue.component('total-time', gl.cycleAnalytics.TotalTimeComponent); -}); diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js new file mode 100644 index 00000000000..9f74b14c4b9 --- /dev/null +++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js @@ -0,0 +1,41 @@ +/* eslint-disable no-param-reassign */ +((global) => { + global.cycleAnalytics = global.cycleAnalytics || {}; + + class CycleAnalyticsService { + constructor(options) { + this.requestPath = options.requestPath; + } + + fetchCycleAnalyticsData(options) { + options = options || { startDate: 30 }; + + return $.ajax({ + url: this.requestPath, + method: 'GET', + dataType: 'json', + contentType: 'application/json', + data: { + cycle_analytics: { + start_date: options.startDate, + }, + }, + }); + } + + fetchStageData(options) { + const { + stage, + startDate, + } = options; + + return $.get(`${this.requestPath}/events/${stage.title.toLowerCase()}.json`, { + cycle_analytics: { + start_date: startDate, + }, + }); + } + } + + global.cycleAnalytics.CycleAnalyticsService = CycleAnalyticsService; +})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js.es6 b/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js.es6 deleted file mode 100644 index 9f74b14c4b9..00000000000 --- a/app/assets/javascripts/cycle_analytics/cycle_analytics_service.js.es6 +++ /dev/null @@ -1,41 +0,0 @@ -/* eslint-disable no-param-reassign */ -((global) => { - global.cycleAnalytics = global.cycleAnalytics || {}; - - class CycleAnalyticsService { - constructor(options) { - this.requestPath = options.requestPath; - } - - fetchCycleAnalyticsData(options) { - options = options || { startDate: 30 }; - - return $.ajax({ - url: this.requestPath, - method: 'GET', - dataType: 'json', - contentType: 'application/json', - data: { - cycle_analytics: { - start_date: options.startDate, - }, - }, - }); - } - - fetchStageData(options) { - const { - stage, - startDate, - } = options; - - return $.get(`${this.requestPath}/events/${stage.title.toLowerCase()}.json`, { - cycle_analytics: { - start_date: startDate, - }, - }); - } - } - - global.cycleAnalytics.CycleAnalyticsService = CycleAnalyticsService; -})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js new file mode 100644 index 00000000000..7ae9de7297c --- /dev/null +++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js @@ -0,0 +1,104 @@ +/* eslint-disable no-param-reassign */ + +require('../lib/utils/text_utility'); +const DEFAULT_EVENT_OBJECTS = require('./default_event_objects'); + +((global) => { + global.cycleAnalytics = global.cycleAnalytics || {}; + + const EMPTY_STAGE_TEXTS = { + issue: 'The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.', + plan: 'The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.', + code: 'The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.', + test: 'The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.', + review: 'The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.', + staging: 'The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.', + production: 'The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.', + }; + + global.cycleAnalytics.CycleAnalyticsStore = { + state: { + summary: '', + stats: '', + analytics: '', + events: [], + stages: [], + }, + setCycleAnalyticsData(data) { + this.state = Object.assign(this.state, this.decorateData(data)); + }, + decorateData(data) { + const newData = {}; + + newData.stages = data.stats || []; + newData.summary = data.summary || []; + + newData.summary.forEach((item) => { + item.value = item.value || '-'; + }); + + newData.stages.forEach((item) => { + const stageSlug = gl.text.dasherize(item.title.toLowerCase()); + item.active = false; + item.isUserAllowed = data.permissions[stageSlug]; + item.emptyStageText = EMPTY_STAGE_TEXTS[stageSlug]; + item.component = `stage-${stageSlug}-component`; + item.slug = stageSlug; + }); + newData.analytics = data; + return newData; + }, + setLoadingState(state) { + this.state.isLoading = state; + }, + setErrorState(state) { + this.state.hasError = state; + }, + deactivateAllStages() { + this.state.stages.forEach((stage) => { + stage.active = false; + }); + }, + setActiveStage(stage) { + this.deactivateAllStages(); + stage.active = true; + }, + setStageEvents(events, stage) { + this.state.events = this.decorateEvents(events, stage); + }, + decorateEvents(events, stage) { + const newEvents = []; + + events.forEach((item) => { + if (!item) return; + + const eventItem = Object.assign({}, DEFAULT_EVENT_OBJECTS[stage.slug], item); + + eventItem.totalTime = eventItem.total_time; + + if (eventItem.author) { + eventItem.author.webUrl = eventItem.author.web_url; + eventItem.author.avatarUrl = eventItem.author.avatar_url; + } + + if (eventItem.created_at) eventItem.createdAt = eventItem.created_at; + if (eventItem.short_sha) eventItem.shortSha = eventItem.short_sha; + if (eventItem.commit_url) eventItem.commitUrl = eventItem.commit_url; + + delete eventItem.author.web_url; + delete eventItem.author.avatar_url; + delete eventItem.total_time; + delete eventItem.created_at; + delete eventItem.short_sha; + delete eventItem.commit_url; + + newEvents.push(eventItem); + }); + + return newEvents; + }, + currentActiveStage() { + return this.state.stages.find(stage => stage.active); + }, + }; +})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js.es6 b/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js.es6 deleted file mode 100644 index 7ae9de7297c..00000000000 --- a/app/assets/javascripts/cycle_analytics/cycle_analytics_store.js.es6 +++ /dev/null @@ -1,104 +0,0 @@ -/* eslint-disable no-param-reassign */ - -require('../lib/utils/text_utility'); -const DEFAULT_EVENT_OBJECTS = require('./default_event_objects'); - -((global) => { - global.cycleAnalytics = global.cycleAnalytics || {}; - - const EMPTY_STAGE_TEXTS = { - issue: 'The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.', - plan: 'The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.', - code: 'The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.', - test: 'The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.', - review: 'The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.', - staging: 'The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.', - production: 'The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.', - }; - - global.cycleAnalytics.CycleAnalyticsStore = { - state: { - summary: '', - stats: '', - analytics: '', - events: [], - stages: [], - }, - setCycleAnalyticsData(data) { - this.state = Object.assign(this.state, this.decorateData(data)); - }, - decorateData(data) { - const newData = {}; - - newData.stages = data.stats || []; - newData.summary = data.summary || []; - - newData.summary.forEach((item) => { - item.value = item.value || '-'; - }); - - newData.stages.forEach((item) => { - const stageSlug = gl.text.dasherize(item.title.toLowerCase()); - item.active = false; - item.isUserAllowed = data.permissions[stageSlug]; - item.emptyStageText = EMPTY_STAGE_TEXTS[stageSlug]; - item.component = `stage-${stageSlug}-component`; - item.slug = stageSlug; - }); - newData.analytics = data; - return newData; - }, - setLoadingState(state) { - this.state.isLoading = state; - }, - setErrorState(state) { - this.state.hasError = state; - }, - deactivateAllStages() { - this.state.stages.forEach((stage) => { - stage.active = false; - }); - }, - setActiveStage(stage) { - this.deactivateAllStages(); - stage.active = true; - }, - setStageEvents(events, stage) { - this.state.events = this.decorateEvents(events, stage); - }, - decorateEvents(events, stage) { - const newEvents = []; - - events.forEach((item) => { - if (!item) return; - - const eventItem = Object.assign({}, DEFAULT_EVENT_OBJECTS[stage.slug], item); - - eventItem.totalTime = eventItem.total_time; - - if (eventItem.author) { - eventItem.author.webUrl = eventItem.author.web_url; - eventItem.author.avatarUrl = eventItem.author.avatar_url; - } - - if (eventItem.created_at) eventItem.createdAt = eventItem.created_at; - if (eventItem.short_sha) eventItem.shortSha = eventItem.short_sha; - if (eventItem.commit_url) eventItem.commitUrl = eventItem.commit_url; - - delete eventItem.author.web_url; - delete eventItem.author.avatar_url; - delete eventItem.total_time; - delete eventItem.created_at; - delete eventItem.short_sha; - delete eventItem.commit_url; - - newEvents.push(eventItem); - }); - - return newEvents; - }, - currentActiveStage() { - return this.state.stages.find(stage => stage.active); - }, - }; -})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/cycle_analytics/default_event_objects.js b/app/assets/javascripts/cycle_analytics/default_event_objects.js new file mode 100644 index 00000000000..cfaf9835bf8 --- /dev/null +++ b/app/assets/javascripts/cycle_analytics/default_event_objects.js @@ -0,0 +1,98 @@ +module.exports = { + issue: { + created_at: '', + url: '', + iid: '', + title: '', + total_time: {}, + author: { + avatar_url: '', + id: '', + name: '', + web_url: '', + }, + }, + plan: { + title: '', + commit_url: '', + short_sha: '', + total_time: {}, + author: { + name: '', + id: '', + avatar_url: '', + web_url: '', + }, + }, + code: { + title: '', + iid: '', + created_at: '', + url: '', + total_time: {}, + author: { + name: '', + id: '', + avatar_url: '', + web_url: '', + }, + }, + test: { + name: '', + id: '', + date: '', + url: '', + short_sha: '', + commit_url: '', + total_time: {}, + branch: { + name: '', + url: '', + }, + }, + review: { + title: '', + iid: '', + created_at: '', + url: '', + state: '', + total_time: {}, + author: { + name: '', + id: '', + avatar_url: '', + web_url: '', + }, + }, + staging: { + id: '', + short_sha: '', + date: '', + url: '', + commit_url: '', + total_time: {}, + author: { + name: '', + id: '', + avatar_url: '', + web_url: '', + }, + branch: { + name: '', + url: '', + }, + }, + production: { + title: '', + created_at: '', + url: '', + iid: '', + total_time: {}, + author: { + name: '', + id: '', + avatar_url: '', + web_url: '', + }, + }, +}; diff --git a/app/assets/javascripts/cycle_analytics/default_event_objects.js.es6 b/app/assets/javascripts/cycle_analytics/default_event_objects.js.es6 deleted file mode 100644 index cfaf9835bf8..00000000000 --- a/app/assets/javascripts/cycle_analytics/default_event_objects.js.es6 +++ /dev/null @@ -1,98 +0,0 @@ -module.exports = { - issue: { - created_at: '', - url: '', - iid: '', - title: '', - total_time: {}, - author: { - avatar_url: '', - id: '', - name: '', - web_url: '', - }, - }, - plan: { - title: '', - commit_url: '', - short_sha: '', - total_time: {}, - author: { - name: '', - id: '', - avatar_url: '', - web_url: '', - }, - }, - code: { - title: '', - iid: '', - created_at: '', - url: '', - total_time: {}, - author: { - name: '', - id: '', - avatar_url: '', - web_url: '', - }, - }, - test: { - name: '', - id: '', - date: '', - url: '', - short_sha: '', - commit_url: '', - total_time: {}, - branch: { - name: '', - url: '', - }, - }, - review: { - title: '', - iid: '', - created_at: '', - url: '', - state: '', - total_time: {}, - author: { - name: '', - id: '', - avatar_url: '', - web_url: '', - }, - }, - staging: { - id: '', - short_sha: '', - date: '', - url: '', - commit_url: '', - total_time: {}, - author: { - name: '', - id: '', - avatar_url: '', - web_url: '', - }, - branch: { - name: '', - url: '', - }, - }, - production: { - title: '', - created_at: '', - url: '', - iid: '', - total_time: {}, - author: { - name: '', - id: '', - avatar_url: '', - web_url: '', - }, - }, -}; diff --git a/app/assets/javascripts/diff.js b/app/assets/javascripts/diff.js new file mode 100644 index 00000000000..6829e8aeaea --- /dev/null +++ b/app/assets/javascripts/diff.js @@ -0,0 +1,130 @@ +/* eslint-disable class-methods-use-this */ + +require('./lib/utils/url_utility'); + +(() => { + const UNFOLD_COUNT = 20; + let isBound = false; + + class Diff { + constructor() { + const $diffFile = $('.files .diff-file'); + $diffFile.singleFileDiff(); + $diffFile.filesCommentButton(); + + $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 (gl.utils.getLocationHash()) { + this.highlightSelectedLine(); + } + + 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 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(); + } + } + + 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(); + } + + 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'); + + 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; +})(); diff --git a/app/assets/javascripts/diff.js.es6 b/app/assets/javascripts/diff.js.es6 deleted file mode 100644 index 6829e8aeaea..00000000000 --- a/app/assets/javascripts/diff.js.es6 +++ /dev/null @@ -1,130 +0,0 @@ -/* eslint-disable class-methods-use-this */ - -require('./lib/utils/url_utility'); - -(() => { - const UNFOLD_COUNT = 20; - let isBound = false; - - class Diff { - constructor() { - const $diffFile = $('.files .diff-file'); - $diffFile.singleFileDiff(); - $diffFile.filesCommentButton(); - - $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 (gl.utils.getLocationHash()) { - this.highlightSelectedLine(); - } - - 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 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(); - } - } - - 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(); - } - - 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'); - - 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; -})(); diff --git a/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js b/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js new file mode 100644 index 00000000000..d948dff58ec --- /dev/null +++ b/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js @@ -0,0 +1,60 @@ +/* eslint-disable comma-dangle, object-shorthand, func-names, no-else-return, quotes, no-lonely-if, max-len */ +/* global CommentsStore */ +const Vue = require('vue'); + +(() => { + const CommentAndResolveBtn = Vue.extend({ + props: { + discussionId: String, + }, + data() { + return { + textareaIsEmpty: true, + discussion: {}, + }; + }, + computed: { + showButton: function () { + if (this.discussion) { + return this.discussion.isResolvable(); + } else { + return false; + } + }, + isDiscussionResolved: function () { + return this.discussion.isResolved(); + }, + buttonText: function () { + if (this.isDiscussionResolved) { + if (this.textareaIsEmpty) { + return "Unresolve discussion"; + } else { + return "Comment & unresolve discussion"; + } + } else { + if (this.textareaIsEmpty) { + return "Resolve discussion"; + } else { + return "Comment & resolve discussion"; + } + } + } + }, + created() { + this.discussion = CommentsStore.state[this.discussionId]; + }, + mounted: function () { + const $textarea = $(`#new-discussion-note-form-${this.discussionId} .note-textarea`); + this.textareaIsEmpty = $textarea.val() === ''; + + $textarea.on('input.comment-and-resolve-btn', () => { + this.textareaIsEmpty = $textarea.val() === ''; + }); + }, + destroyed: function () { + $(`#new-discussion-note-form-${this.discussionId} .note-textarea`).off('input.comment-and-resolve-btn'); + } + }); + + Vue.component('comment-and-resolve-btn', CommentAndResolveBtn); +})(window); diff --git a/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js.es6 b/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js.es6 deleted file mode 100644 index d948dff58ec..00000000000 --- a/app/assets/javascripts/diff_notes/components/comment_resolve_btn.js.es6 +++ /dev/null @@ -1,60 +0,0 @@ -/* eslint-disable comma-dangle, object-shorthand, func-names, no-else-return, quotes, no-lonely-if, max-len */ -/* global CommentsStore */ -const Vue = require('vue'); - -(() => { - const CommentAndResolveBtn = Vue.extend({ - props: { - discussionId: String, - }, - data() { - return { - textareaIsEmpty: true, - discussion: {}, - }; - }, - computed: { - showButton: function () { - if (this.discussion) { - return this.discussion.isResolvable(); - } else { - return false; - } - }, - isDiscussionResolved: function () { - return this.discussion.isResolved(); - }, - buttonText: function () { - if (this.isDiscussionResolved) { - if (this.textareaIsEmpty) { - return "Unresolve discussion"; - } else { - return "Comment & unresolve discussion"; - } - } else { - if (this.textareaIsEmpty) { - return "Resolve discussion"; - } else { - return "Comment & resolve discussion"; - } - } - } - }, - created() { - this.discussion = CommentsStore.state[this.discussionId]; - }, - mounted: function () { - const $textarea = $(`#new-discussion-note-form-${this.discussionId} .note-textarea`); - this.textareaIsEmpty = $textarea.val() === ''; - - $textarea.on('input.comment-and-resolve-btn', () => { - this.textareaIsEmpty = $textarea.val() === ''; - }); - }, - destroyed: function () { - $(`#new-discussion-note-form-${this.discussionId} .note-textarea`).off('input.comment-and-resolve-btn'); - } - }); - - Vue.component('comment-and-resolve-btn', CommentAndResolveBtn); -})(window); diff --git a/app/assets/javascripts/diff_notes/components/jump_to_discussion.js b/app/assets/javascripts/diff_notes/components/jump_to_discussion.js new file mode 100644 index 00000000000..283dc330cad --- /dev/null +++ b/app/assets/javascripts/diff_notes/components/jump_to_discussion.js @@ -0,0 +1,194 @@ +/* eslint-disable comma-dangle, object-shorthand, func-names, no-else-return, guard-for-in, no-restricted-syntax, one-var, space-before-function-paren, no-lonely-if, no-continue, brace-style, max-len, quotes */ +/* global DiscussionMixins */ +/* global CommentsStore */ +const Vue = require('vue'); + +(() => { + const JumpToDiscussion = Vue.extend({ + mixins: [DiscussionMixins], + props: { + discussionId: String + }, + data: function () { + return { + discussions: CommentsStore.state, + discussion: {}, + }; + }, + computed: { + allResolved: function () { + return this.unresolvedDiscussionCount === 0; + }, + showButton: function () { + if (this.discussionId) { + if (this.unresolvedDiscussionCount > 1) { + return true; + } else { + return this.discussionId !== this.lastResolvedId; + } + } else { + return this.unresolvedDiscussionCount >= 1; + } + }, + lastResolvedId: function () { + let lastId; + for (const discussionId in this.discussions) { + const discussion = this.discussions[discussionId]; + + if (!discussion.isResolved()) { + lastId = discussion.id; + } + } + return lastId; + } + }, + methods: { + jumpToNextUnresolvedDiscussion: function () { + let discussionsSelector; + let discussionIdsInScope; + let firstUnresolvedDiscussionId; + let nextUnresolvedDiscussionId; + let activeTab = window.mrTabs.currentAction; + let hasDiscussionsToJumpTo = true; + let jumpToFirstDiscussion = !this.discussionId; + + const discussionIdsForElements = function(elements) { + return elements.map(function() { + return $(this).attr('data-discussion-id'); + }).toArray(); + }; + + const discussions = this.discussions; + + if (activeTab === 'diffs') { + discussionsSelector = '.diffs .notes[data-discussion-id]'; + discussionIdsInScope = discussionIdsForElements($(discussionsSelector)); + + let unresolvedDiscussionCount = 0; + + for (let i = 0; i < discussionIdsInScope.length; i += 1) { + const discussionId = discussionIdsInScope[i]; + const discussion = discussions[discussionId]; + if (discussion && !discussion.isResolved()) { + unresolvedDiscussionCount += 1; + } + } + + if (this.discussionId && !this.discussion.isResolved()) { + // If this is the last unresolved discussion on the diffs tab, + // there are no discussions to jump to. + if (unresolvedDiscussionCount === 1) { + hasDiscussionsToJumpTo = false; + } + } else { + // If there are no unresolved discussions on the diffs tab at all, + // there are no discussions to jump to. + if (unresolvedDiscussionCount === 0) { + hasDiscussionsToJumpTo = false; + } + } + } else if (activeTab !== 'notes') { + // If we are on the commits or builds tabs, + // there are no discussions to jump to. + hasDiscussionsToJumpTo = false; + } + + if (!hasDiscussionsToJumpTo) { + // If there are no discussions to jump to on the current page, + // switch to the notes tab and jump to the first disucssion there. + window.mrTabs.activateTab('notes'); + activeTab = 'notes'; + jumpToFirstDiscussion = true; + } + + if (activeTab === 'notes') { + discussionsSelector = '.discussion[data-discussion-id]'; + discussionIdsInScope = discussionIdsForElements($(discussionsSelector)); + } + + let currentDiscussionFound = false; + for (let i = 0; i < discussionIdsInScope.length; i += 1) { + const discussionId = discussionIdsInScope[i]; + const discussion = discussions[discussionId]; + + if (!discussion) { + // Discussions for comments on commits in this MR don't have a resolved status. + continue; + } + + if (!firstUnresolvedDiscussionId && !discussion.isResolved()) { + firstUnresolvedDiscussionId = discussionId; + + if (jumpToFirstDiscussion) { + break; + } + } + + if (!jumpToFirstDiscussion) { + if (currentDiscussionFound) { + if (!discussion.isResolved()) { + nextUnresolvedDiscussionId = discussionId; + break; + } + else { + continue; + } + } + + if (discussionId === this.discussionId) { + currentDiscussionFound = true; + } + } + } + + nextUnresolvedDiscussionId = nextUnresolvedDiscussionId || firstUnresolvedDiscussionId; + + if (!nextUnresolvedDiscussionId) { + return; + } + + let $target = $(`${discussionsSelector}[data-discussion-id="${nextUnresolvedDiscussionId}"]`); + + if (activeTab === 'notes') { + $target = $target.closest('.note-discussion'); + + // If the next discussion is closed, toggle it open. + if ($target.find('.js-toggle-content').is(':hidden')) { + $target.find('.js-toggle-button i').trigger('click'); + } + } else if (activeTab === 'diffs') { + // Resolved discussions are hidden in the diffs tab by default. + // If they are marked unresolved on the notes tab, they will still be hidden on the diffs tab. + // When jumping between unresolved discussions on the diffs tab, we show them. + $target.closest(".content").show(); + + $target = $target.closest("tr.notes_holder"); + $target.show(); + + // If we are on the diffs tab, we don't scroll to the discussion itself, but to + // 4 diff lines above it: the line the discussion was in response to + 3 context + let prevEl; + for (let i = 0; i < 4; i += 1) { + prevEl = $target.prev(); + + // If the discussion doesn't have 4 lines above it, we'll have to do with fewer. + if (!prevEl.hasClass("line_holder")) { + break; + } + + $target = prevEl; + } + } + + $.scrollTo($target, { + offset: 0 + }); + } + }, + created() { + this.discussion = this.discussions[this.discussionId]; + }, + }); + + Vue.component('jump-to-discussion', JumpToDiscussion); +})(); diff --git a/app/assets/javascripts/diff_notes/components/jump_to_discussion.js.es6 b/app/assets/javascripts/diff_notes/components/jump_to_discussion.js.es6 deleted file mode 100644 index 283dc330cad..00000000000 --- a/app/assets/javascripts/diff_notes/components/jump_to_discussion.js.es6 +++ /dev/null @@ -1,194 +0,0 @@ -/* eslint-disable comma-dangle, object-shorthand, func-names, no-else-return, guard-for-in, no-restricted-syntax, one-var, space-before-function-paren, no-lonely-if, no-continue, brace-style, max-len, quotes */ -/* global DiscussionMixins */ -/* global CommentsStore */ -const Vue = require('vue'); - -(() => { - const JumpToDiscussion = Vue.extend({ - mixins: [DiscussionMixins], - props: { - discussionId: String - }, - data: function () { - return { - discussions: CommentsStore.state, - discussion: {}, - }; - }, - computed: { - allResolved: function () { - return this.unresolvedDiscussionCount === 0; - }, - showButton: function () { - if (this.discussionId) { - if (this.unresolvedDiscussionCount > 1) { - return true; - } else { - return this.discussionId !== this.lastResolvedId; - } - } else { - return this.unresolvedDiscussionCount >= 1; - } - }, - lastResolvedId: function () { - let lastId; - for (const discussionId in this.discussions) { - const discussion = this.discussions[discussionId]; - - if (!discussion.isResolved()) { - lastId = discussion.id; - } - } - return lastId; - } - }, - methods: { - jumpToNextUnresolvedDiscussion: function () { - let discussionsSelector; - let discussionIdsInScope; - let firstUnresolvedDiscussionId; - let nextUnresolvedDiscussionId; - let activeTab = window.mrTabs.currentAction; - let hasDiscussionsToJumpTo = true; - let jumpToFirstDiscussion = !this.discussionId; - - const discussionIdsForElements = function(elements) { - return elements.map(function() { - return $(this).attr('data-discussion-id'); - }).toArray(); - }; - - const discussions = this.discussions; - - if (activeTab === 'diffs') { - discussionsSelector = '.diffs .notes[data-discussion-id]'; - discussionIdsInScope = discussionIdsForElements($(discussionsSelector)); - - let unresolvedDiscussionCount = 0; - - for (let i = 0; i < discussionIdsInScope.length; i += 1) { - const discussionId = discussionIdsInScope[i]; - const discussion = discussions[discussionId]; - if (discussion && !discussion.isResolved()) { - unresolvedDiscussionCount += 1; - } - } - - if (this.discussionId && !this.discussion.isResolved()) { - // If this is the last unresolved discussion on the diffs tab, - // there are no discussions to jump to. - if (unresolvedDiscussionCount === 1) { - hasDiscussionsToJumpTo = false; - } - } else { - // If there are no unresolved discussions on the diffs tab at all, - // there are no discussions to jump to. - if (unresolvedDiscussionCount === 0) { - hasDiscussionsToJumpTo = false; - } - } - } else if (activeTab !== 'notes') { - // If we are on the commits or builds tabs, - // there are no discussions to jump to. - hasDiscussionsToJumpTo = false; - } - - if (!hasDiscussionsToJumpTo) { - // If there are no discussions to jump to on the current page, - // switch to the notes tab and jump to the first disucssion there. - window.mrTabs.activateTab('notes'); - activeTab = 'notes'; - jumpToFirstDiscussion = true; - } - - if (activeTab === 'notes') { - discussionsSelector = '.discussion[data-discussion-id]'; - discussionIdsInScope = discussionIdsForElements($(discussionsSelector)); - } - - let currentDiscussionFound = false; - for (let i = 0; i < discussionIdsInScope.length; i += 1) { - const discussionId = discussionIdsInScope[i]; - const discussion = discussions[discussionId]; - - if (!discussion) { - // Discussions for comments on commits in this MR don't have a resolved status. - continue; - } - - if (!firstUnresolvedDiscussionId && !discussion.isResolved()) { - firstUnresolvedDiscussionId = discussionId; - - if (jumpToFirstDiscussion) { - break; - } - } - - if (!jumpToFirstDiscussion) { - if (currentDiscussionFound) { - if (!discussion.isResolved()) { - nextUnresolvedDiscussionId = discussionId; - break; - } - else { - continue; - } - } - - if (discussionId === this.discussionId) { - currentDiscussionFound = true; - } - } - } - - nextUnresolvedDiscussionId = nextUnresolvedDiscussionId || firstUnresolvedDiscussionId; - - if (!nextUnresolvedDiscussionId) { - return; - } - - let $target = $(`${discussionsSelector}[data-discussion-id="${nextUnresolvedDiscussionId}"]`); - - if (activeTab === 'notes') { - $target = $target.closest('.note-discussion'); - - // If the next discussion is closed, toggle it open. - if ($target.find('.js-toggle-content').is(':hidden')) { - $target.find('.js-toggle-button i').trigger('click'); - } - } else if (activeTab === 'diffs') { - // Resolved discussions are hidden in the diffs tab by default. - // If they are marked unresolved on the notes tab, they will still be hidden on the diffs tab. - // When jumping between unresolved discussions on the diffs tab, we show them. - $target.closest(".content").show(); - - $target = $target.closest("tr.notes_holder"); - $target.show(); - - // If we are on the diffs tab, we don't scroll to the discussion itself, but to - // 4 diff lines above it: the line the discussion was in response to + 3 context - let prevEl; - for (let i = 0; i < 4; i += 1) { - prevEl = $target.prev(); - - // If the discussion doesn't have 4 lines above it, we'll have to do with fewer. - if (!prevEl.hasClass("line_holder")) { - break; - } - - $target = prevEl; - } - } - - $.scrollTo($target, { - offset: 0 - }); - } - }, - created() { - this.discussion = this.discussions[this.discussionId]; - }, - }); - - Vue.component('jump-to-discussion', JumpToDiscussion); -})(); diff --git a/app/assets/javascripts/diff_notes/components/resolve_btn.js b/app/assets/javascripts/diff_notes/components/resolve_btn.js new file mode 100644 index 00000000000..d1873d6c7a2 --- /dev/null +++ b/app/assets/javascripts/diff_notes/components/resolve_btn.js @@ -0,0 +1,108 @@ +/* eslint-disable comma-dangle, object-shorthand, func-names, quote-props, no-else-return, camelcase, no-new, max-len */ +/* global CommentsStore */ +/* global ResolveService */ +/* global Flash */ +const Vue = require('vue'); + +(() => { + const ResolveBtn = Vue.extend({ + props: { + noteId: Number, + discussionId: String, + resolved: Boolean, + canResolve: Boolean, + resolvedBy: String + }, + data: function () { + return { + discussions: CommentsStore.state, + loading: false, + note: {}, + }; + }, + watch: { + 'discussions': { + handler: 'updateTooltip', + deep: true + } + }, + computed: { + discussion: function () { + return this.discussions[this.discussionId]; + }, + buttonText: function () { + if (this.isResolved) { + return `Resolved by ${this.resolvedByName}`; + } else if (this.canResolve) { + return 'Mark as resolved'; + } else { + return 'Unable to resolve'; + } + }, + isResolved: function () { + if (this.note) { + return this.note.resolved; + } else { + return false; + } + }, + resolvedByName: function () { + return this.note.resolved_by; + }, + }, + methods: { + updateTooltip: function () { + this.$nextTick(() => { + $(this.$refs.button) + .tooltip('hide') + .tooltip('fixTitle'); + }); + }, + resolve: function () { + if (!this.canResolve) return; + + let promise; + this.loading = true; + + if (this.isResolved) { + promise = ResolveService + .unresolve(this.noteId); + } else { + promise = ResolveService + .resolve(this.noteId); + } + + promise.then((response) => { + this.loading = false; + + if (response.status === 200) { + const data = response.json(); + const resolved_by = data ? data.resolved_by : null; + + CommentsStore.update(this.discussionId, this.noteId, !this.isResolved, resolved_by); + this.discussion.updateHeadline(data); + } else { + new Flash('An error occurred when trying to resolve a comment. Please try again.', 'alert'); + } + + this.updateTooltip(); + }); + } + }, + mounted: function () { + $(this.$refs.button).tooltip({ + container: 'body' + }); + }, + beforeDestroy: function () { + CommentsStore.delete(this.discussionId, this.noteId); + }, + created: function () { + CommentsStore.create(this.discussionId, this.noteId, this.canResolve, this.resolved, this.resolvedBy); + + this.note = this.discussion.getNote(this.noteId); + } + }); + + Vue.component('resolve-btn', ResolveBtn); +})(); diff --git a/app/assets/javascripts/diff_notes/components/resolve_btn.js.es6 b/app/assets/javascripts/diff_notes/components/resolve_btn.js.es6 deleted file mode 100644 index d1873d6c7a2..00000000000 --- a/app/assets/javascripts/diff_notes/components/resolve_btn.js.es6 +++ /dev/null @@ -1,108 +0,0 @@ -/* eslint-disable comma-dangle, object-shorthand, func-names, quote-props, no-else-return, camelcase, no-new, max-len */ -/* global CommentsStore */ -/* global ResolveService */ -/* global Flash */ -const Vue = require('vue'); - -(() => { - const ResolveBtn = Vue.extend({ - props: { - noteId: Number, - discussionId: String, - resolved: Boolean, - canResolve: Boolean, - resolvedBy: String - }, - data: function () { - return { - discussions: CommentsStore.state, - loading: false, - note: {}, - }; - }, - watch: { - 'discussions': { - handler: 'updateTooltip', - deep: true - } - }, - computed: { - discussion: function () { - return this.discussions[this.discussionId]; - }, - buttonText: function () { - if (this.isResolved) { - return `Resolved by ${this.resolvedByName}`; - } else if (this.canResolve) { - return 'Mark as resolved'; - } else { - return 'Unable to resolve'; - } - }, - isResolved: function () { - if (this.note) { - return this.note.resolved; - } else { - return false; - } - }, - resolvedByName: function () { - return this.note.resolved_by; - }, - }, - methods: { - updateTooltip: function () { - this.$nextTick(() => { - $(this.$refs.button) - .tooltip('hide') - .tooltip('fixTitle'); - }); - }, - resolve: function () { - if (!this.canResolve) return; - - let promise; - this.loading = true; - - if (this.isResolved) { - promise = ResolveService - .unresolve(this.noteId); - } else { - promise = ResolveService - .resolve(this.noteId); - } - - promise.then((response) => { - this.loading = false; - - if (response.status === 200) { - const data = response.json(); - const resolved_by = data ? data.resolved_by : null; - - CommentsStore.update(this.discussionId, this.noteId, !this.isResolved, resolved_by); - this.discussion.updateHeadline(data); - } else { - new Flash('An error occurred when trying to resolve a comment. Please try again.', 'alert'); - } - - this.updateTooltip(); - }); - } - }, - mounted: function () { - $(this.$refs.button).tooltip({ - container: 'body' - }); - }, - beforeDestroy: function () { - CommentsStore.delete(this.discussionId, this.noteId); - }, - created: function () { - CommentsStore.create(this.discussionId, this.noteId, this.canResolve, this.resolved, this.resolvedBy); - - this.note = this.discussion.getNote(this.noteId); - } - }); - - Vue.component('resolve-btn', ResolveBtn); -})(); diff --git a/app/assets/javascripts/diff_notes/components/resolve_count.js b/app/assets/javascripts/diff_notes/components/resolve_count.js new file mode 100644 index 00000000000..de9367f2136 --- /dev/null +++ b/app/assets/javascripts/diff_notes/components/resolve_count.js @@ -0,0 +1,26 @@ +/* eslint-disable comma-dangle, object-shorthand, func-names, no-param-reassign */ +/* global DiscussionMixins */ +/* global CommentsStore */ +const Vue = require('vue'); + +((w) => { + w.ResolveCount = Vue.extend({ + mixins: [DiscussionMixins], + props: { + loggedOut: Boolean + }, + data: function () { + return { + discussions: CommentsStore.state + }; + }, + computed: { + allResolved: function () { + return this.resolvedDiscussionCount === this.discussionCount; + }, + resolvedCountText() { + return this.discussionCount === 1 ? 'discussion' : 'discussions'; + } + } + }); +})(window); diff --git a/app/assets/javascripts/diff_notes/components/resolve_count.js.es6 b/app/assets/javascripts/diff_notes/components/resolve_count.js.es6 deleted file mode 100644 index de9367f2136..00000000000 --- a/app/assets/javascripts/diff_notes/components/resolve_count.js.es6 +++ /dev/null @@ -1,26 +0,0 @@ -/* eslint-disable comma-dangle, object-shorthand, func-names, no-param-reassign */ -/* global DiscussionMixins */ -/* global CommentsStore */ -const Vue = require('vue'); - -((w) => { - w.ResolveCount = Vue.extend({ - mixins: [DiscussionMixins], - props: { - loggedOut: Boolean - }, - data: function () { - return { - discussions: CommentsStore.state - }; - }, - computed: { - allResolved: function () { - return this.resolvedDiscussionCount === this.discussionCount; - }, - resolvedCountText() { - return this.discussionCount === 1 ? 'discussion' : 'discussions'; - } - } - }); -})(window); diff --git a/app/assets/javascripts/diff_notes/components/resolve_discussion_btn.js b/app/assets/javascripts/diff_notes/components/resolve_discussion_btn.js new file mode 100644 index 00000000000..7c5fcd04d2d --- /dev/null +++ b/app/assets/javascripts/diff_notes/components/resolve_discussion_btn.js @@ -0,0 +1,62 @@ +/* eslint-disable object-shorthand, func-names, space-before-function-paren, comma-dangle, no-else-return, quotes, max-len */ +/* global CommentsStore */ +/* global ResolveService */ + +const Vue = require('vue'); + +(() => { + const ResolveDiscussionBtn = Vue.extend({ + props: { + discussionId: String, + mergeRequestId: Number, + canResolve: Boolean, + }, + data: function() { + return { + discussion: {}, + }; + }, + computed: { + showButton: function () { + if (this.discussion) { + return this.discussion.isResolvable(); + } else { + return false; + } + }, + isDiscussionResolved: function () { + if (this.discussion) { + return this.discussion.isResolved(); + } else { + return false; + } + }, + buttonText: function () { + if (this.isDiscussionResolved) { + return "Unresolve discussion"; + } else { + return "Resolve discussion"; + } + }, + loading: function () { + if (this.discussion) { + return this.discussion.loading; + } else { + return false; + } + } + }, + methods: { + resolve: function () { + ResolveService.toggleResolveForDiscussion(this.mergeRequestId, this.discussionId); + } + }, + created: function () { + CommentsStore.createDiscussion(this.discussionId, this.canResolve); + + this.discussion = CommentsStore.state[this.discussionId]; + } + }); + + Vue.component('resolve-discussion-btn', ResolveDiscussionBtn); +})(); diff --git a/app/assets/javascripts/diff_notes/components/resolve_discussion_btn.js.es6 b/app/assets/javascripts/diff_notes/components/resolve_discussion_btn.js.es6 deleted file mode 100644 index 7c5fcd04d2d..00000000000 --- a/app/assets/javascripts/diff_notes/components/resolve_discussion_btn.js.es6 +++ /dev/null @@ -1,62 +0,0 @@ -/* eslint-disable object-shorthand, func-names, space-before-function-paren, comma-dangle, no-else-return, quotes, max-len */ -/* global CommentsStore */ -/* global ResolveService */ - -const Vue = require('vue'); - -(() => { - const ResolveDiscussionBtn = Vue.extend({ - props: { - discussionId: String, - mergeRequestId: Number, - canResolve: Boolean, - }, - data: function() { - return { - discussion: {}, - }; - }, - computed: { - showButton: function () { - if (this.discussion) { - return this.discussion.isResolvable(); - } else { - return false; - } - }, - isDiscussionResolved: function () { - if (this.discussion) { - return this.discussion.isResolved(); - } else { - return false; - } - }, - buttonText: function () { - if (this.isDiscussionResolved) { - return "Unresolve discussion"; - } else { - return "Resolve discussion"; - } - }, - loading: function () { - if (this.discussion) { - return this.discussion.loading; - } else { - return false; - } - } - }, - methods: { - resolve: function () { - ResolveService.toggleResolveForDiscussion(this.mergeRequestId, this.discussionId); - } - }, - created: function () { - CommentsStore.createDiscussion(this.discussionId, this.canResolve); - - this.discussion = CommentsStore.state[this.discussionId]; - } - }); - - Vue.component('resolve-discussion-btn', ResolveDiscussionBtn); -})(); diff --git a/app/assets/javascripts/diff_notes/diff_notes_bundle.js b/app/assets/javascripts/diff_notes/diff_notes_bundle.js new file mode 100644 index 00000000000..cadf8b96b87 --- /dev/null +++ b/app/assets/javascripts/diff_notes/diff_notes_bundle.js @@ -0,0 +1,57 @@ +/* eslint-disable func-names, comma-dangle, new-cap, no-new, max-len */ +/* global Vue */ +/* global ResolveCount */ + +const Vue = require('vue'); +require('./models/discussion'); +require('./models/note'); +require('./stores/comments'); +require('./services/resolve'); +require('./mixins/discussion'); +require('./components/comment_resolve_btn'); +require('./components/jump_to_discussion'); +require('./components/resolve_btn'); +require('./components/resolve_count'); +require('./components/resolve_discussion_btn'); + +$(() => { + const projectPath = document.querySelector('.merge-request').dataset.projectPath; + const COMPONENT_SELECTOR = 'resolve-btn, resolve-discussion-btn, jump-to-discussion, comment-and-resolve-btn'; + + window.gl = window.gl || {}; + window.gl.diffNoteApps = {}; + + window.ResolveService = new gl.DiffNotesResolveServiceClass(projectPath); + + gl.diffNotesCompileComponents = () => { + const $components = $(COMPONENT_SELECTOR).filter(function () { + return $(this).closest('resolve-count').length !== 1; + }); + + if ($components) { + $components.each(function () { + const $this = $(this); + const noteId = $this.attr(':note-id'); + const tmp = Vue.extend({ + template: $this.get(0).outerHTML + }); + const tmpApp = new tmp().$mount(); + + if (noteId) { + gl.diffNoteApps[`note_${noteId}`] = tmpApp; + } + + $this.replaceWith(tmpApp.$el); + }); + } + }; + + gl.diffNotesCompileComponents(); + + new Vue({ + el: '#resolve-count-app', + components: { + 'resolve-count': ResolveCount + } + }); +}); diff --git a/app/assets/javascripts/diff_notes/diff_notes_bundle.js.es6 b/app/assets/javascripts/diff_notes/diff_notes_bundle.js.es6 deleted file mode 100644 index cadf8b96b87..00000000000 --- a/app/assets/javascripts/diff_notes/diff_notes_bundle.js.es6 +++ /dev/null @@ -1,57 +0,0 @@ -/* eslint-disable func-names, comma-dangle, new-cap, no-new, max-len */ -/* global Vue */ -/* global ResolveCount */ - -const Vue = require('vue'); -require('./models/discussion'); -require('./models/note'); -require('./stores/comments'); -require('./services/resolve'); -require('./mixins/discussion'); -require('./components/comment_resolve_btn'); -require('./components/jump_to_discussion'); -require('./components/resolve_btn'); -require('./components/resolve_count'); -require('./components/resolve_discussion_btn'); - -$(() => { - const projectPath = document.querySelector('.merge-request').dataset.projectPath; - const COMPONENT_SELECTOR = 'resolve-btn, resolve-discussion-btn, jump-to-discussion, comment-and-resolve-btn'; - - window.gl = window.gl || {}; - window.gl.diffNoteApps = {}; - - window.ResolveService = new gl.DiffNotesResolveServiceClass(projectPath); - - gl.diffNotesCompileComponents = () => { - const $components = $(COMPONENT_SELECTOR).filter(function () { - return $(this).closest('resolve-count').length !== 1; - }); - - if ($components) { - $components.each(function () { - const $this = $(this); - const noteId = $this.attr(':note-id'); - const tmp = Vue.extend({ - template: $this.get(0).outerHTML - }); - const tmpApp = new tmp().$mount(); - - if (noteId) { - gl.diffNoteApps[`note_${noteId}`] = tmpApp; - } - - $this.replaceWith(tmpApp.$el); - }); - } - }; - - gl.diffNotesCompileComponents(); - - new Vue({ - el: '#resolve-count-app', - components: { - 'resolve-count': ResolveCount - } - }); -}); diff --git a/app/assets/javascripts/diff_notes/mixins/discussion.js b/app/assets/javascripts/diff_notes/mixins/discussion.js new file mode 100644 index 00000000000..3c08c222f46 --- /dev/null +++ b/app/assets/javascripts/diff_notes/mixins/discussion.js @@ -0,0 +1,37 @@ +/* eslint-disable object-shorthand, func-names, guard-for-in, no-restricted-syntax, comma-dangle, no-param-reassign, max-len */ + +((w) => { + w.DiscussionMixins = { + computed: { + discussionCount: function () { + return Object.keys(this.discussions).length; + }, + resolvedDiscussionCount: function () { + let resolvedCount = 0; + + for (const discussionId in this.discussions) { + const discussion = this.discussions[discussionId]; + + if (discussion.isResolved()) { + resolvedCount += 1; + } + } + + return resolvedCount; + }, + unresolvedDiscussionCount: function () { + let unresolvedCount = 0; + + for (const discussionId in this.discussions) { + const discussion = this.discussions[discussionId]; + + if (!discussion.isResolved()) { + unresolvedCount += 1; + } + } + + return unresolvedCount; + } + } + }; +})(window); diff --git a/app/assets/javascripts/diff_notes/mixins/discussion.js.es6 b/app/assets/javascripts/diff_notes/mixins/discussion.js.es6 deleted file mode 100644 index 3c08c222f46..00000000000 --- a/app/assets/javascripts/diff_notes/mixins/discussion.js.es6 +++ /dev/null @@ -1,37 +0,0 @@ -/* eslint-disable object-shorthand, func-names, guard-for-in, no-restricted-syntax, comma-dangle, no-param-reassign, max-len */ - -((w) => { - w.DiscussionMixins = { - computed: { - discussionCount: function () { - return Object.keys(this.discussions).length; - }, - resolvedDiscussionCount: function () { - let resolvedCount = 0; - - for (const discussionId in this.discussions) { - const discussion = this.discussions[discussionId]; - - if (discussion.isResolved()) { - resolvedCount += 1; - } - } - - return resolvedCount; - }, - unresolvedDiscussionCount: function () { - let unresolvedCount = 0; - - for (const discussionId in this.discussions) { - const discussion = this.discussions[discussionId]; - - if (!discussion.isResolved()) { - unresolvedCount += 1; - } - } - - return unresolvedCount; - } - } - }; -})(window); diff --git a/app/assets/javascripts/diff_notes/models/discussion.js b/app/assets/javascripts/diff_notes/models/discussion.js new file mode 100644 index 00000000000..fa518ba4d33 --- /dev/null +++ b/app/assets/javascripts/diff_notes/models/discussion.js @@ -0,0 +1,96 @@ +/* eslint-disable space-before-function-paren, camelcase, guard-for-in, no-restricted-syntax, no-unused-vars, max-len */ +/* global Vue */ +/* global NoteModel */ + +class DiscussionModel { + constructor (discussionId) { + this.id = discussionId; + this.notes = {}; + this.loading = false; + this.canResolve = false; + } + + createNote (noteId, canResolve, resolved, resolved_by) { + Vue.set(this.notes, noteId, new NoteModel(this.id, noteId, canResolve, resolved, resolved_by)); + } + + deleteNote (noteId) { + Vue.delete(this.notes, noteId); + } + + getNote (noteId) { + return this.notes[noteId]; + } + + notesCount() { + return Object.keys(this.notes).length; + } + + isResolved () { + for (const noteId in this.notes) { + const note = this.notes[noteId]; + + if (!note.resolved) { + return false; + } + } + return true; + } + + resolveAllNotes (resolved_by) { + for (const noteId in this.notes) { + const note = this.notes[noteId]; + + if (!note.resolved) { + note.resolved = true; + note.resolved_by = resolved_by; + } + } + } + + unResolveAllNotes () { + for (const noteId in this.notes) { + const note = this.notes[noteId]; + + if (note.resolved) { + note.resolved = false; + note.resolved_by = null; + } + } + } + + updateHeadline (data) { + const discussionSelector = `.discussion[data-discussion-id="${this.id}"]`; + const $discussionHeadline = $(`${discussionSelector} .js-discussion-headline`); + + if (data.discussion_headline_html) { + if ($discussionHeadline.length) { + $discussionHeadline.replaceWith(data.discussion_headline_html); + } else { + $(`${discussionSelector} .discussion-header`).append(data.discussion_headline_html); + } + + gl.utils.localTimeAgo($('.js-timeago', `${discussionSelector}`)); + } else { + $discussionHeadline.remove(); + } + } + + isResolvable () { + if (!this.canResolve) { + return false; + } + + for (const noteId in this.notes) { + const note = this.notes[noteId]; + + if (note.canResolve) { + return true; + } + } + + return false; + } +} + +window.DiscussionModel = DiscussionModel; diff --git a/app/assets/javascripts/diff_notes/models/discussion.js.es6 b/app/assets/javascripts/diff_notes/models/discussion.js.es6 deleted file mode 100644 index fa518ba4d33..00000000000 --- a/app/assets/javascripts/diff_notes/models/discussion.js.es6 +++ /dev/null @@ -1,96 +0,0 @@ -/* eslint-disable space-before-function-paren, camelcase, guard-for-in, no-restricted-syntax, no-unused-vars, max-len */ -/* global Vue */ -/* global NoteModel */ - -class DiscussionModel { - constructor (discussionId) { - this.id = discussionId; - this.notes = {}; - this.loading = false; - this.canResolve = false; - } - - createNote (noteId, canResolve, resolved, resolved_by) { - Vue.set(this.notes, noteId, new NoteModel(this.id, noteId, canResolve, resolved, resolved_by)); - } - - deleteNote (noteId) { - Vue.delete(this.notes, noteId); - } - - getNote (noteId) { - return this.notes[noteId]; - } - - notesCount() { - return Object.keys(this.notes).length; - } - - isResolved () { - for (const noteId in this.notes) { - const note = this.notes[noteId]; - - if (!note.resolved) { - return false; - } - } - return true; - } - - resolveAllNotes (resolved_by) { - for (const noteId in this.notes) { - const note = this.notes[noteId]; - - if (!note.resolved) { - note.resolved = true; - note.resolved_by = resolved_by; - } - } - } - - unResolveAllNotes () { - for (const noteId in this.notes) { - const note = this.notes[noteId]; - - if (note.resolved) { - note.resolved = false; - note.resolved_by = null; - } - } - } - - updateHeadline (data) { - const discussionSelector = `.discussion[data-discussion-id="${this.id}"]`; - const $discussionHeadline = $(`${discussionSelector} .js-discussion-headline`); - - if (data.discussion_headline_html) { - if ($discussionHeadline.length) { - $discussionHeadline.replaceWith(data.discussion_headline_html); - } else { - $(`${discussionSelector} .discussion-header`).append(data.discussion_headline_html); - } - - gl.utils.localTimeAgo($('.js-timeago', `${discussionSelector}`)); - } else { - $discussionHeadline.remove(); - } - } - - isResolvable () { - if (!this.canResolve) { - return false; - } - - for (const noteId in this.notes) { - const note = this.notes[noteId]; - - if (note.canResolve) { - return true; - } - } - - return false; - } -} - -window.DiscussionModel = DiscussionModel; diff --git a/app/assets/javascripts/diff_notes/models/note.js b/app/assets/javascripts/diff_notes/models/note.js new file mode 100644 index 00000000000..f3a7cba5ef6 --- /dev/null +++ b/app/assets/javascripts/diff_notes/models/note.js @@ -0,0 +1,13 @@ +/* eslint-disable camelcase, no-unused-vars */ + +class NoteModel { + constructor(discussionId, noteId, canResolve, resolved, resolved_by) { + this.discussionId = discussionId; + this.id = noteId; + this.canResolve = canResolve; + this.resolved = resolved; + this.resolved_by = resolved_by; + } +} + +window.NoteModel = NoteModel; diff --git a/app/assets/javascripts/diff_notes/models/note.js.es6 b/app/assets/javascripts/diff_notes/models/note.js.es6 deleted file mode 100644 index f3a7cba5ef6..00000000000 --- a/app/assets/javascripts/diff_notes/models/note.js.es6 +++ /dev/null @@ -1,13 +0,0 @@ -/* eslint-disable camelcase, no-unused-vars */ - -class NoteModel { - constructor(discussionId, noteId, canResolve, resolved, resolved_by) { - this.discussionId = discussionId; - this.id = noteId; - this.canResolve = canResolve; - this.resolved = resolved; - this.resolved_by = resolved_by; - } -} - -window.NoteModel = NoteModel; diff --git a/app/assets/javascripts/diff_notes/services/resolve.js b/app/assets/javascripts/diff_notes/services/resolve.js new file mode 100644 index 00000000000..090c454e9e4 --- /dev/null +++ b/app/assets/javascripts/diff_notes/services/resolve.js @@ -0,0 +1,81 @@ +/* eslint-disable class-methods-use-this, one-var, camelcase, no-new, comma-dangle, no-param-reassign, max-len */ +/* global Flash */ +/* global CommentsStore */ + +const Vue = window.Vue = require('vue'); +window.Vue.use(require('vue-resource')); +require('../../vue_shared/vue_resource_interceptor'); + +(() => { + window.gl = window.gl || {}; + + class ResolveServiceClass { + constructor(root) { + this.noteResource = Vue.resource(`${root}/notes{/noteId}/resolve`); + this.discussionResource = Vue.resource(`${root}/merge_requests{/mergeRequestId}/discussions{/discussionId}/resolve`); + } + + resolve(noteId) { + return this.noteResource.save({ noteId }, {}); + } + + unresolve(noteId) { + return this.noteResource.delete({ noteId }, {}); + } + + toggleResolveForDiscussion(mergeRequestId, discussionId) { + const discussion = CommentsStore.state[discussionId]; + const isResolved = discussion.isResolved(); + let promise; + + if (isResolved) { + promise = this.unResolveAll(mergeRequestId, discussionId); + } else { + promise = this.resolveAll(mergeRequestId, discussionId); + } + + promise.then((response) => { + discussion.loading = false; + + if (response.status === 200) { + const data = response.json(); + const resolved_by = data ? data.resolved_by : null; + + if (isResolved) { + discussion.unResolveAllNotes(); + } else { + discussion.resolveAllNotes(resolved_by); + } + + discussion.updateHeadline(data); + } else { + new Flash('An error occurred when trying to resolve a discussion. Please try again.', 'alert'); + } + }); + } + + resolveAll(mergeRequestId, discussionId) { + const discussion = CommentsStore.state[discussionId]; + + discussion.loading = true; + + return this.discussionResource.save({ + mergeRequestId, + discussionId + }, {}); + } + + unResolveAll(mergeRequestId, discussionId) { + const discussion = CommentsStore.state[discussionId]; + + discussion.loading = true; + + return this.discussionResource.delete({ + mergeRequestId, + discussionId + }, {}); + } + } + + gl.DiffNotesResolveServiceClass = ResolveServiceClass; +})(); diff --git a/app/assets/javascripts/diff_notes/services/resolve.js.es6 b/app/assets/javascripts/diff_notes/services/resolve.js.es6 deleted file mode 100644 index 090c454e9e4..00000000000 --- a/app/assets/javascripts/diff_notes/services/resolve.js.es6 +++ /dev/null @@ -1,81 +0,0 @@ -/* eslint-disable class-methods-use-this, one-var, camelcase, no-new, comma-dangle, no-param-reassign, max-len */ -/* global Flash */ -/* global CommentsStore */ - -const Vue = window.Vue = require('vue'); -window.Vue.use(require('vue-resource')); -require('../../vue_shared/vue_resource_interceptor'); - -(() => { - window.gl = window.gl || {}; - - class ResolveServiceClass { - constructor(root) { - this.noteResource = Vue.resource(`${root}/notes{/noteId}/resolve`); - this.discussionResource = Vue.resource(`${root}/merge_requests{/mergeRequestId}/discussions{/discussionId}/resolve`); - } - - resolve(noteId) { - return this.noteResource.save({ noteId }, {}); - } - - unresolve(noteId) { - return this.noteResource.delete({ noteId }, {}); - } - - toggleResolveForDiscussion(mergeRequestId, discussionId) { - const discussion = CommentsStore.state[discussionId]; - const isResolved = discussion.isResolved(); - let promise; - - if (isResolved) { - promise = this.unResolveAll(mergeRequestId, discussionId); - } else { - promise = this.resolveAll(mergeRequestId, discussionId); - } - - promise.then((response) => { - discussion.loading = false; - - if (response.status === 200) { - const data = response.json(); - const resolved_by = data ? data.resolved_by : null; - - if (isResolved) { - discussion.unResolveAllNotes(); - } else { - discussion.resolveAllNotes(resolved_by); - } - - discussion.updateHeadline(data); - } else { - new Flash('An error occurred when trying to resolve a discussion. Please try again.', 'alert'); - } - }); - } - - resolveAll(mergeRequestId, discussionId) { - const discussion = CommentsStore.state[discussionId]; - - discussion.loading = true; - - return this.discussionResource.save({ - mergeRequestId, - discussionId - }, {}); - } - - unResolveAll(mergeRequestId, discussionId) { - const discussion = CommentsStore.state[discussionId]; - - discussion.loading = true; - - return this.discussionResource.delete({ - mergeRequestId, - discussionId - }, {}); - } - } - - gl.DiffNotesResolveServiceClass = ResolveServiceClass; -})(); diff --git a/app/assets/javascripts/diff_notes/stores/comments.js b/app/assets/javascripts/diff_notes/stores/comments.js new file mode 100644 index 00000000000..c80d979b977 --- /dev/null +++ b/app/assets/javascripts/diff_notes/stores/comments.js @@ -0,0 +1,57 @@ +/* eslint-disable object-shorthand, func-names, camelcase, no-restricted-syntax, guard-for-in, comma-dangle, max-len, no-param-reassign */ +/* global Vue */ +/* global DiscussionModel */ + +((w) => { + w.CommentsStore = { + state: {}, + get: function (discussionId, noteId) { + return this.state[discussionId].getNote(noteId); + }, + createDiscussion: function (discussionId, canResolve) { + let discussion = this.state[discussionId]; + if (!this.state[discussionId]) { + discussion = new DiscussionModel(discussionId); + Vue.set(this.state, discussionId, discussion); + } + + if (canResolve !== undefined) { + discussion.canResolve = canResolve; + } + + return discussion; + }, + create: function (discussionId, noteId, canResolve, resolved, resolved_by) { + const discussion = this.createDiscussion(discussionId); + + discussion.createNote(noteId, canResolve, resolved, resolved_by); + }, + update: function (discussionId, noteId, resolved, resolved_by) { + const discussion = this.state[discussionId]; + const note = discussion.getNote(noteId); + note.resolved = resolved; + note.resolved_by = resolved_by; + }, + delete: function (discussionId, noteId) { + const discussion = this.state[discussionId]; + discussion.deleteNote(noteId); + + if (discussion.notesCount() === 0) { + Vue.delete(this.state, discussionId); + } + }, + unresolvedDiscussionIds: function () { + const ids = []; + + for (const discussionId in this.state) { + const discussion = this.state[discussionId]; + + if (!discussion.isResolved()) { + ids.push(discussion.id); + } + } + + return ids; + } + }; +})(window); diff --git a/app/assets/javascripts/diff_notes/stores/comments.js.es6 b/app/assets/javascripts/diff_notes/stores/comments.js.es6 deleted file mode 100644 index c80d979b977..00000000000 --- a/app/assets/javascripts/diff_notes/stores/comments.js.es6 +++ /dev/null @@ -1,57 +0,0 @@ -/* eslint-disable object-shorthand, func-names, camelcase, no-restricted-syntax, guard-for-in, comma-dangle, max-len, no-param-reassign */ -/* global Vue */ -/* global DiscussionModel */ - -((w) => { - w.CommentsStore = { - state: {}, - get: function (discussionId, noteId) { - return this.state[discussionId].getNote(noteId); - }, - createDiscussion: function (discussionId, canResolve) { - let discussion = this.state[discussionId]; - if (!this.state[discussionId]) { - discussion = new DiscussionModel(discussionId); - Vue.set(this.state, discussionId, discussion); - } - - if (canResolve !== undefined) { - discussion.canResolve = canResolve; - } - - return discussion; - }, - create: function (discussionId, noteId, canResolve, resolved, resolved_by) { - const discussion = this.createDiscussion(discussionId); - - discussion.createNote(noteId, canResolve, resolved, resolved_by); - }, - update: function (discussionId, noteId, resolved, resolved_by) { - const discussion = this.state[discussionId]; - const note = discussion.getNote(noteId); - note.resolved = resolved; - note.resolved_by = resolved_by; - }, - delete: function (discussionId, noteId) { - const discussion = this.state[discussionId]; - discussion.deleteNote(noteId); - - if (discussion.notesCount() === 0) { - Vue.delete(this.state, discussionId); - } - }, - unresolvedDiscussionIds: function () { - const ids = []; - - for (const discussionId in this.state) { - const discussion = this.state[discussionId]; - - if (!discussion.isResolved()) { - ids.push(discussion.id); - } - } - - return ids; - } - }; -})(window); diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js new file mode 100644 index 00000000000..ef5785b5532 --- /dev/null +++ b/app/assets/javascripts/dispatcher.js @@ -0,0 +1,407 @@ +/* eslint-disable func-names, space-before-function-paren, no-var, prefer-arrow-callback, wrap-iife, no-shadow, consistent-return, one-var, one-var-declaration-per-line, camelcase, default-case, no-new, quotes, no-duplicate-case, no-case-declarations, no-fallthrough, max-len */ +/* global UsernameValidator */ +/* global ActiveTabMemoizer */ +/* global ShortcutsNavigation */ +/* global Build */ +/* global Issuable */ +/* global Issue */ +/* global ShortcutsIssuable */ +/* global ZenMode */ +/* global Milestone */ +/* global IssuableForm */ +/* global LabelsSelect */ +/* global MilestoneSelect */ +/* global MergedButtons */ +/* global Commit */ +/* global NotificationsForm */ +/* global TreeView */ +/* global NotificationsDropdown */ +/* global UsersSelect */ +/* global GroupAvatar */ +/* global LineHighlighter */ +/* global ProjectFork */ +/* global BuildArtifacts */ +/* global GroupsSelect */ +/* global Search */ +/* global Admin */ +/* global NamespaceSelects */ +/* global ShortcutsDashboardNavigation */ +/* global Project */ +/* global ProjectAvatar */ +/* global CompareAutocomplete */ +/* global ProjectNew */ +/* global Star */ +/* global ProjectShow */ +/* global Labels */ +/* global Shortcuts */ + +import GroupsList from './groups_list'; +import ProjectsList from './projects_list'; + +const ShortcutsBlob = require('./shortcuts_blob'); +const UserCallout = require('./user_callout'); + +(function() { + var Dispatcher; + + $(function() { + return new Dispatcher(); + }); + + Dispatcher = (function() { + function Dispatcher() { + this.initSearch(); + this.initFieldErrors(); + this.initPageScripts(); + } + + Dispatcher.prototype.initPageScripts = function() { + var page, path, shortcut_handler; + page = $('body').attr('data-page'); + if (!page) { + return false; + } + path = page.split(':'); + shortcut_handler = null; + switch (page) { + case 'sessions:new': + new UsernameValidator(); + new ActiveTabMemoizer(); + break; + case 'projects:boards:show': + case 'projects:boards:index': + shortcut_handler = new ShortcutsNavigation(); + break; + case 'projects:builds:show': + new Build(); + break; + case 'projects:merge_requests:index': + case 'projects:issues:index': + if (gl.FilteredSearchManager) { + new gl.FilteredSearchManager(page === 'projects:issues:index' ? 'issues' : 'merge_requests'); + } + Issuable.init(); + new gl.IssuableBulkActions({ + prefixId: page === 'projects:merge_requests:index' ? 'merge_request_' : 'issue_', + }); + shortcut_handler = new ShortcutsNavigation(); + break; + case 'projects:issues:show': + new Issue(); + shortcut_handler = new ShortcutsIssuable(); + new ZenMode(); + break; + case 'projects:milestones:show': + case 'groups:milestones:show': + case 'dashboard:milestones:show': + new Milestone(); + break; + case 'dashboard:todos:index': + new gl.Todos(); + break; + case 'dashboard:projects:index': + case 'dashboard:projects:starred': + case 'explore:projects:index': + case 'explore:projects:trending': + case 'explore:projects:starred': + case 'admin:projects:index': + new ProjectsList(); + break; + case 'dashboard:groups:index': + case 'explore:groups:index': + new GroupsList(); + break; + case 'projects:milestones:new': + case 'projects:milestones:edit': + case 'projects:milestones:update': + new ZenMode(); + new gl.DueDateSelectors(); + new gl.GLForm($('.milestone-form')); + break; + case 'groups:milestones:new': + new ZenMode(); + break; + case 'projects:compare:show': + new gl.Diff(); + break; + case 'projects:branches:index': + gl.AjaxLoadingSpinner.init(); + break; + case 'projects:issues:new': + case 'projects:issues:edit': + shortcut_handler = new ShortcutsNavigation(); + new gl.GLForm($('.issue-form')); + new IssuableForm($('.issue-form')); + new LabelsSelect(); + new MilestoneSelect(); + new gl.IssuableTemplateSelectors(); + break; + case 'projects:merge_requests:new': + case 'projects:merge_requests:new_diffs': + case 'projects:merge_requests:edit': + new gl.Diff(); + shortcut_handler = new ShortcutsNavigation(); + new gl.GLForm($('.merge-request-form')); + new IssuableForm($('.merge-request-form')); + new LabelsSelect(); + new MilestoneSelect(); + new gl.IssuableTemplateSelectors(); + break; + case 'projects:tags:new': + new ZenMode(); + new gl.GLForm($('.tag-form')); + break; + case 'projects:releases:edit': + new ZenMode(); + new gl.GLForm($('.release-form')); + break; + case 'projects:merge_requests:show': + new gl.Diff(); + shortcut_handler = new ShortcutsIssuable(true); + new ZenMode(); + new MergedButtons(); + break; + case 'projects:merge_requests:commits': + new MergedButtons(); + break; + case "projects:merge_requests:diffs": + new gl.Diff(); + new ZenMode(); + new MergedButtons(); + break; + case 'dashboard:activity': + new gl.Activities(); + break; + case 'projects:commit:show': + new Commit(); + new gl.Diff(); + new ZenMode(); + shortcut_handler = new ShortcutsNavigation(); + break; + case 'projects:commit:pipelines': + new gl.MiniPipelineGraph({ + container: '.js-pipeline-table', + }).bindEvents(); + break; + case 'projects:commits:show': + case 'projects:activity': + shortcut_handler = new ShortcutsNavigation(); + break; + case 'projects:show': + shortcut_handler = new ShortcutsNavigation(); + new NotificationsForm(); + if ($('#tree-slider').length) { + new TreeView(); + } + break; + case 'projects:pipelines:builds': + case 'projects:pipelines:show': + const { controllerAction } = document.querySelector('.js-pipeline-container').dataset; + + new gl.Pipelines({ + initTabs: true, + tabsOptions: { + action: controllerAction, + defaultAction: 'pipelines', + parentEl: '.pipelines-tabs', + }, + }); + break; + case 'groups:activity': + new gl.Activities(); + break; + case 'groups:show': + shortcut_handler = new ShortcutsNavigation(); + new NotificationsForm(); + new NotificationsDropdown(); + new ProjectsList(); + break; + case 'groups:group_members:index': + new gl.MemberExpirationDate(); + new gl.Members(); + new UsersSelect(); + break; + case 'projects:members:show': + new gl.MemberExpirationDate('.js-access-expiration-date-groups'); + new GroupsSelect(); + new gl.MemberExpirationDate(); + new gl.Members(); + new UsersSelect(); + break; + case 'groups:new': + case 'groups:edit': + case 'admin:groups:edit': + case 'admin:groups:new': + new GroupAvatar(); + break; + case 'projects:tree:show': + shortcut_handler = new ShortcutsNavigation(); + new TreeView(); + break; + case 'projects:find_file:show': + shortcut_handler = true; + break; + case 'projects:blob:show': + case 'projects:blame:show': + new LineHighlighter(); + shortcut_handler = new ShortcutsNavigation(); + const fileBlobPermalinkUrlElement = document.querySelector('.js-data-file-blob-permalink-url'); + const fileBlobPermalinkUrl = fileBlobPermalinkUrlElement && fileBlobPermalinkUrlElement.getAttribute('href'); + new ShortcutsBlob({ + skipResetBindings: true, + fileBlobPermalinkUrl, + }); + break; + case 'groups:labels:new': + case 'groups:labels:edit': + case 'projects:labels:new': + case 'projects:labels:edit': + new Labels(); + break; + case 'projects:labels:index': + if ($('.prioritized-labels').length) { + new gl.LabelManager(); + } + break; + case 'projects:network:show': + // Ensure we don't create a particular shortcut handler here. This is + // already created, where the network graph is created. + shortcut_handler = true; + break; + case 'projects:forks:new': + new ProjectFork(); + break; + case 'projects:artifacts:browse': + new BuildArtifacts(); + break; + case 'help:index': + gl.VersionCheckImage.bindErrorEvent($('img.js-version-status-badge')); + break; + case 'search:show': + new Search(); + break; + case 'projects:protected_branches:index': + new gl.ProtectedBranchCreate(); + new gl.ProtectedBranchEditList(); + break; + case 'projects:ci_cd:show': + new gl.ProjectVariables(); + break; + case 'ci:lints:create': + case 'ci:lints:show': + new gl.CILintEditor(); + break; + case 'users:show': + new UserCallout(); + break; + } + switch (path.first()) { + case 'sessions': + case 'omniauth_callbacks': + if (!gon.u2f) break; + gl.u2fAuthenticate = new gl.U2FAuthenticate( + $('#js-authenticate-u2f'), + '#js-login-u2f-form', + gon.u2f, + document.querySelector('#js-login-2fa-device'), + document.querySelector('.js-2fa-form'), + ); + gl.u2fAuthenticate.start(); + case 'admin': + new Admin(); + switch (path[1]) { + case 'groups': + new UsersSelect(); + break; + case 'projects': + new NamespaceSelects(); + break; + case 'labels': + switch (path[2]) { + case 'new': + case 'edit': + new Labels(); + } + case 'abuse_reports': + new gl.AbuseReports(); + break; + } + break; + case 'dashboard': + case 'root': + shortcut_handler = new ShortcutsDashboardNavigation(); + new UserCallout(); + break; + case 'profiles': + new NotificationsForm(); + new NotificationsDropdown(); + break; + case 'projects': + new Project(); + new ProjectAvatar(); + switch (path[1]) { + case 'compare': + new CompareAutocomplete(); + break; + case 'edit': + shortcut_handler = new ShortcutsNavigation(); + new ProjectNew(); + break; + case 'new': + new ProjectNew(); + break; + case 'show': + new Star(); + new ProjectNew(); + new ProjectShow(); + new NotificationsDropdown(); + break; + case 'wikis': + new gl.Wikis(); + shortcut_handler = new ShortcutsNavigation(); + new ZenMode(); + new gl.GLForm($('.wiki-form')); + break; + case 'snippets': + shortcut_handler = new ShortcutsNavigation(); + if (path[2] === 'show') { + new ZenMode(); + } + break; + case 'labels': + case 'graphs': + case 'compare': + case 'pipelines': + case 'forks': + case 'milestones': + case 'project_members': + case 'deploy_keys': + case 'builds': + case 'hooks': + case 'services': + case 'protected_branches': + shortcut_handler = new ShortcutsNavigation(); + } + } + // If we haven't installed a custom shortcut handler, install the default one + if (!shortcut_handler) { + new Shortcuts(); + } + }; + + Dispatcher.prototype.initSearch = function() { + // Only when search form is present + if ($('.search').length) { + return new gl.SearchAutocomplete(); + } + }; + + Dispatcher.prototype.initFieldErrors = function() { + $('.gl-show-field-errors').each((i, form) => { + new gl.GlFieldErrors(form); + }); + }; + + return Dispatcher; + })(); +}).call(window); diff --git a/app/assets/javascripts/dispatcher.js.es6 b/app/assets/javascripts/dispatcher.js.es6 deleted file mode 100644 index ef5785b5532..00000000000 --- a/app/assets/javascripts/dispatcher.js.es6 +++ /dev/null @@ -1,407 +0,0 @@ -/* eslint-disable func-names, space-before-function-paren, no-var, prefer-arrow-callback, wrap-iife, no-shadow, consistent-return, one-var, one-var-declaration-per-line, camelcase, default-case, no-new, quotes, no-duplicate-case, no-case-declarations, no-fallthrough, max-len */ -/* global UsernameValidator */ -/* global ActiveTabMemoizer */ -/* global ShortcutsNavigation */ -/* global Build */ -/* global Issuable */ -/* global Issue */ -/* global ShortcutsIssuable */ -/* global ZenMode */ -/* global Milestone */ -/* global IssuableForm */ -/* global LabelsSelect */ -/* global MilestoneSelect */ -/* global MergedButtons */ -/* global Commit */ -/* global NotificationsForm */ -/* global TreeView */ -/* global NotificationsDropdown */ -/* global UsersSelect */ -/* global GroupAvatar */ -/* global LineHighlighter */ -/* global ProjectFork */ -/* global BuildArtifacts */ -/* global GroupsSelect */ -/* global Search */ -/* global Admin */ -/* global NamespaceSelects */ -/* global ShortcutsDashboardNavigation */ -/* global Project */ -/* global ProjectAvatar */ -/* global CompareAutocomplete */ -/* global ProjectNew */ -/* global Star */ -/* global ProjectShow */ -/* global Labels */ -/* global Shortcuts */ - -import GroupsList from './groups_list'; -import ProjectsList from './projects_list'; - -const ShortcutsBlob = require('./shortcuts_blob'); -const UserCallout = require('./user_callout'); - -(function() { - var Dispatcher; - - $(function() { - return new Dispatcher(); - }); - - Dispatcher = (function() { - function Dispatcher() { - this.initSearch(); - this.initFieldErrors(); - this.initPageScripts(); - } - - Dispatcher.prototype.initPageScripts = function() { - var page, path, shortcut_handler; - page = $('body').attr('data-page'); - if (!page) { - return false; - } - path = page.split(':'); - shortcut_handler = null; - switch (page) { - case 'sessions:new': - new UsernameValidator(); - new ActiveTabMemoizer(); - break; - case 'projects:boards:show': - case 'projects:boards:index': - shortcut_handler = new ShortcutsNavigation(); - break; - case 'projects:builds:show': - new Build(); - break; - case 'projects:merge_requests:index': - case 'projects:issues:index': - if (gl.FilteredSearchManager) { - new gl.FilteredSearchManager(page === 'projects:issues:index' ? 'issues' : 'merge_requests'); - } - Issuable.init(); - new gl.IssuableBulkActions({ - prefixId: page === 'projects:merge_requests:index' ? 'merge_request_' : 'issue_', - }); - shortcut_handler = new ShortcutsNavigation(); - break; - case 'projects:issues:show': - new Issue(); - shortcut_handler = new ShortcutsIssuable(); - new ZenMode(); - break; - case 'projects:milestones:show': - case 'groups:milestones:show': - case 'dashboard:milestones:show': - new Milestone(); - break; - case 'dashboard:todos:index': - new gl.Todos(); - break; - case 'dashboard:projects:index': - case 'dashboard:projects:starred': - case 'explore:projects:index': - case 'explore:projects:trending': - case 'explore:projects:starred': - case 'admin:projects:index': - new ProjectsList(); - break; - case 'dashboard:groups:index': - case 'explore:groups:index': - new GroupsList(); - break; - case 'projects:milestones:new': - case 'projects:milestones:edit': - case 'projects:milestones:update': - new ZenMode(); - new gl.DueDateSelectors(); - new gl.GLForm($('.milestone-form')); - break; - case 'groups:milestones:new': - new ZenMode(); - break; - case 'projects:compare:show': - new gl.Diff(); - break; - case 'projects:branches:index': - gl.AjaxLoadingSpinner.init(); - break; - case 'projects:issues:new': - case 'projects:issues:edit': - shortcut_handler = new ShortcutsNavigation(); - new gl.GLForm($('.issue-form')); - new IssuableForm($('.issue-form')); - new LabelsSelect(); - new MilestoneSelect(); - new gl.IssuableTemplateSelectors(); - break; - case 'projects:merge_requests:new': - case 'projects:merge_requests:new_diffs': - case 'projects:merge_requests:edit': - new gl.Diff(); - shortcut_handler = new ShortcutsNavigation(); - new gl.GLForm($('.merge-request-form')); - new IssuableForm($('.merge-request-form')); - new LabelsSelect(); - new MilestoneSelect(); - new gl.IssuableTemplateSelectors(); - break; - case 'projects:tags:new': - new ZenMode(); - new gl.GLForm($('.tag-form')); - break; - case 'projects:releases:edit': - new ZenMode(); - new gl.GLForm($('.release-form')); - break; - case 'projects:merge_requests:show': - new gl.Diff(); - shortcut_handler = new ShortcutsIssuable(true); - new ZenMode(); - new MergedButtons(); - break; - case 'projects:merge_requests:commits': - new MergedButtons(); - break; - case "projects:merge_requests:diffs": - new gl.Diff(); - new ZenMode(); - new MergedButtons(); - break; - case 'dashboard:activity': - new gl.Activities(); - break; - case 'projects:commit:show': - new Commit(); - new gl.Diff(); - new ZenMode(); - shortcut_handler = new ShortcutsNavigation(); - break; - case 'projects:commit:pipelines': - new gl.MiniPipelineGraph({ - container: '.js-pipeline-table', - }).bindEvents(); - break; - case 'projects:commits:show': - case 'projects:activity': - shortcut_handler = new ShortcutsNavigation(); - break; - case 'projects:show': - shortcut_handler = new ShortcutsNavigation(); - new NotificationsForm(); - if ($('#tree-slider').length) { - new TreeView(); - } - break; - case 'projects:pipelines:builds': - case 'projects:pipelines:show': - const { controllerAction } = document.querySelector('.js-pipeline-container').dataset; - - new gl.Pipelines({ - initTabs: true, - tabsOptions: { - action: controllerAction, - defaultAction: 'pipelines', - parentEl: '.pipelines-tabs', - }, - }); - break; - case 'groups:activity': - new gl.Activities(); - break; - case 'groups:show': - shortcut_handler = new ShortcutsNavigation(); - new NotificationsForm(); - new NotificationsDropdown(); - new ProjectsList(); - break; - case 'groups:group_members:index': - new gl.MemberExpirationDate(); - new gl.Members(); - new UsersSelect(); - break; - case 'projects:members:show': - new gl.MemberExpirationDate('.js-access-expiration-date-groups'); - new GroupsSelect(); - new gl.MemberExpirationDate(); - new gl.Members(); - new UsersSelect(); - break; - case 'groups:new': - case 'groups:edit': - case 'admin:groups:edit': - case 'admin:groups:new': - new GroupAvatar(); - break; - case 'projects:tree:show': - shortcut_handler = new ShortcutsNavigation(); - new TreeView(); - break; - case 'projects:find_file:show': - shortcut_handler = true; - break; - case 'projects:blob:show': - case 'projects:blame:show': - new LineHighlighter(); - shortcut_handler = new ShortcutsNavigation(); - const fileBlobPermalinkUrlElement = document.querySelector('.js-data-file-blob-permalink-url'); - const fileBlobPermalinkUrl = fileBlobPermalinkUrlElement && fileBlobPermalinkUrlElement.getAttribute('href'); - new ShortcutsBlob({ - skipResetBindings: true, - fileBlobPermalinkUrl, - }); - break; - case 'groups:labels:new': - case 'groups:labels:edit': - case 'projects:labels:new': - case 'projects:labels:edit': - new Labels(); - break; - case 'projects:labels:index': - if ($('.prioritized-labels').length) { - new gl.LabelManager(); - } - break; - case 'projects:network:show': - // Ensure we don't create a particular shortcut handler here. This is - // already created, where the network graph is created. - shortcut_handler = true; - break; - case 'projects:forks:new': - new ProjectFork(); - break; - case 'projects:artifacts:browse': - new BuildArtifacts(); - break; - case 'help:index': - gl.VersionCheckImage.bindErrorEvent($('img.js-version-status-badge')); - break; - case 'search:show': - new Search(); - break; - case 'projects:protected_branches:index': - new gl.ProtectedBranchCreate(); - new gl.ProtectedBranchEditList(); - break; - case 'projects:ci_cd:show': - new gl.ProjectVariables(); - break; - case 'ci:lints:create': - case 'ci:lints:show': - new gl.CILintEditor(); - break; - case 'users:show': - new UserCallout(); - break; - } - switch (path.first()) { - case 'sessions': - case 'omniauth_callbacks': - if (!gon.u2f) break; - gl.u2fAuthenticate = new gl.U2FAuthenticate( - $('#js-authenticate-u2f'), - '#js-login-u2f-form', - gon.u2f, - document.querySelector('#js-login-2fa-device'), - document.querySelector('.js-2fa-form'), - ); - gl.u2fAuthenticate.start(); - case 'admin': - new Admin(); - switch (path[1]) { - case 'groups': - new UsersSelect(); - break; - case 'projects': - new NamespaceSelects(); - break; - case 'labels': - switch (path[2]) { - case 'new': - case 'edit': - new Labels(); - } - case 'abuse_reports': - new gl.AbuseReports(); - break; - } - break; - case 'dashboard': - case 'root': - shortcut_handler = new ShortcutsDashboardNavigation(); - new UserCallout(); - break; - case 'profiles': - new NotificationsForm(); - new NotificationsDropdown(); - break; - case 'projects': - new Project(); - new ProjectAvatar(); - switch (path[1]) { - case 'compare': - new CompareAutocomplete(); - break; - case 'edit': - shortcut_handler = new ShortcutsNavigation(); - new ProjectNew(); - break; - case 'new': - new ProjectNew(); - break; - case 'show': - new Star(); - new ProjectNew(); - new ProjectShow(); - new NotificationsDropdown(); - break; - case 'wikis': - new gl.Wikis(); - shortcut_handler = new ShortcutsNavigation(); - new ZenMode(); - new gl.GLForm($('.wiki-form')); - break; - case 'snippets': - shortcut_handler = new ShortcutsNavigation(); - if (path[2] === 'show') { - new ZenMode(); - } - break; - case 'labels': - case 'graphs': - case 'compare': - case 'pipelines': - case 'forks': - case 'milestones': - case 'project_members': - case 'deploy_keys': - case 'builds': - case 'hooks': - case 'services': - case 'protected_branches': - shortcut_handler = new ShortcutsNavigation(); - } - } - // If we haven't installed a custom shortcut handler, install the default one - if (!shortcut_handler) { - new Shortcuts(); - } - }; - - Dispatcher.prototype.initSearch = function() { - // Only when search form is present - if ($('.search').length) { - return new gl.SearchAutocomplete(); - } - }; - - Dispatcher.prototype.initFieldErrors = function() { - $('.gl-show-field-errors').each((i, form) => { - new gl.GlFieldErrors(form); - }); - }; - - return Dispatcher; - })(); -}).call(window); diff --git a/app/assets/javascripts/due_date_select.js b/app/assets/javascripts/due_date_select.js new file mode 100644 index 00000000000..9169fcd7328 --- /dev/null +++ b/app/assets/javascripts/due_date_select.js @@ -0,0 +1,204 @@ +/* eslint-disable wrap-iife, func-names, space-before-function-paren, comma-dangle, prefer-template, consistent-return, class-methods-use-this, arrow-body-style, no-unused-vars, no-underscore-dangle, no-new, max-len, no-sequences, no-unused-expressions, no-param-reassign */ +/* 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); + + if (this.$dropdown.hasClass('js-issue-boards-due-date')) { + gl.issueBoards.BoardsStore.detail.issue.dueDate = $dueDateInput.val(); + this.updateIssueBoardIssue(); + } else { + this.saveDueDate(true); + } + } + }); + + 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(); + + calendar.setDate(null); + + if (this.$dropdown.hasClass('js-issue-boards-due-date')) { + gl.issueBoards.BoardsStore.detail.issue.dueDate = ''; + this.updateIssueBoardIssue(); + } else { + $("input[name='" + this.fieldName + "']").val(''); + return this.saveDueDate(false); + } + }); + } + + saveDueDate(isDropdown) { + this.parseSelectedDate(); + this.prepSelectedDate(); + this.submitSelectedDate(isDropdown); + } + + parseSelectedDate() { + this.rawSelectedDate = $(`input[name='${this.fieldName}']`).val(); + + 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(); + + 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'); + } + return this.$loading.fadeOut(); + }); + } + } + + 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); + }); + + $('.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 + }); + }); + } + } + + global.DueDateSelectors = DueDateSelectors; +})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/due_date_select.js.es6 b/app/assets/javascripts/due_date_select.js.es6 deleted file mode 100644 index 9169fcd7328..00000000000 --- a/app/assets/javascripts/due_date_select.js.es6 +++ /dev/null @@ -1,204 +0,0 @@ -/* eslint-disable wrap-iife, func-names, space-before-function-paren, comma-dangle, prefer-template, consistent-return, class-methods-use-this, arrow-body-style, no-unused-vars, no-underscore-dangle, no-new, max-len, no-sequences, no-unused-expressions, no-param-reassign */ -/* 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); - - if (this.$dropdown.hasClass('js-issue-boards-due-date')) { - gl.issueBoards.BoardsStore.detail.issue.dueDate = $dueDateInput.val(); - this.updateIssueBoardIssue(); - } else { - this.saveDueDate(true); - } - } - }); - - 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(); - - calendar.setDate(null); - - if (this.$dropdown.hasClass('js-issue-boards-due-date')) { - gl.issueBoards.BoardsStore.detail.issue.dueDate = ''; - this.updateIssueBoardIssue(); - } else { - $("input[name='" + this.fieldName + "']").val(''); - return this.saveDueDate(false); - } - }); - } - - saveDueDate(isDropdown) { - this.parseSelectedDate(); - this.prepSelectedDate(); - this.submitSelectedDate(isDropdown); - } - - parseSelectedDate() { - this.rawSelectedDate = $(`input[name='${this.fieldName}']`).val(); - - 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(); - - 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'); - } - return this.$loading.fadeOut(); - }); - } - } - - 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); - }); - - $('.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 - }); - }); - } - } - - global.DueDateSelectors = DueDateSelectors; -})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/environments/components/environment.js b/app/assets/javascripts/environments/components/environment.js new file mode 100644 index 00000000000..2cb48dde628 --- /dev/null +++ b/app/assets/javascripts/environments/components/environment.js @@ -0,0 +1,186 @@ +/* eslint-disable no-param-reassign, no-new */ +/* global Flash */ + +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', { + + components: { + 'environment-table': EnvironmentTable, + 'table-pagination': gl.VueGlPagination, + }, + + data() { + const environmentsData = document.querySelector('#environments-list-view').dataset; + const store = new EnvironmentsStore(); + + return { + store, + state: store.state, + visibility: 'available', + isLoading: false, + cssContainerClass: environmentsData.cssClass, + endpoint: environmentsData.environmentsDataEndpoint, + canCreateDeployment: environmentsData.canCreateDeployment, + canReadEnvironment: environmentsData.canReadEnvironment, + canCreateEnvironment: environmentsData.canCreateEnvironment, + projectEnvironmentsPath: environmentsData.projectEnvironmentsPath, + projectStoppedEnvironmentsPath: environmentsData.projectStoppedEnvironmentsPath, + newEnvironmentPath: environmentsData.newEnvironmentPath, + helpPagePath: environmentsData.helpPagePath, + + // Pagination Properties, + paginationInformation: {}, + pageNumber: 1, + }; + }, + + computed: { + scope() { + return gl.utils.getParameterByName('scope'); + }, + + canReadEnvironmentParsed() { + return gl.utils.convertPermissionToBoolean(this.canReadEnvironment); + }, + + canCreateDeploymentParsed() { + return gl.utils.convertPermissionToBoolean(this.canCreateDeployment); + }, + + canCreateEnvironmentParsed() { + return gl.utils.convertPermissionToBoolean(this.canCreateEnvironment); + }, + + }, + + /** + * Fetches all the environments and stores them. + * 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'); + }); + }, + + methods: { + toggleRow(model) { + return this.store.toggleFolder(model.name); + }, + + /** + * Will change the page number and update the URL. + * + * @param {Number} pageNumber desired page to go to. + * @return {String} + */ + changePage(pageNumber) { + const param = gl.utils.setParamInURL('page', pageNumber); + + gl.utils.visitUrl(param); + return param; + }, + }, + + template: ` +
    + + +
    +
    + +
    + +
    +

    + You don't have any environments right now. +

    +

    + Environments are places where code gets deployed, such as staging or production. +
    + + Read more about environments + +

    + + + New Environment + +
    + +
    + + +
    + + + +
    +
    + `, +}); diff --git a/app/assets/javascripts/environments/components/environment.js.es6 b/app/assets/javascripts/environments/components/environment.js.es6 deleted file mode 100644 index 2cb48dde628..00000000000 --- a/app/assets/javascripts/environments/components/environment.js.es6 +++ /dev/null @@ -1,186 +0,0 @@ -/* eslint-disable no-param-reassign, no-new */ -/* global Flash */ - -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', { - - components: { - 'environment-table': EnvironmentTable, - 'table-pagination': gl.VueGlPagination, - }, - - data() { - const environmentsData = document.querySelector('#environments-list-view').dataset; - const store = new EnvironmentsStore(); - - return { - store, - state: store.state, - visibility: 'available', - isLoading: false, - cssContainerClass: environmentsData.cssClass, - endpoint: environmentsData.environmentsDataEndpoint, - canCreateDeployment: environmentsData.canCreateDeployment, - canReadEnvironment: environmentsData.canReadEnvironment, - canCreateEnvironment: environmentsData.canCreateEnvironment, - projectEnvironmentsPath: environmentsData.projectEnvironmentsPath, - projectStoppedEnvironmentsPath: environmentsData.projectStoppedEnvironmentsPath, - newEnvironmentPath: environmentsData.newEnvironmentPath, - helpPagePath: environmentsData.helpPagePath, - - // Pagination Properties, - paginationInformation: {}, - pageNumber: 1, - }; - }, - - computed: { - scope() { - return gl.utils.getParameterByName('scope'); - }, - - canReadEnvironmentParsed() { - return gl.utils.convertPermissionToBoolean(this.canReadEnvironment); - }, - - canCreateDeploymentParsed() { - return gl.utils.convertPermissionToBoolean(this.canCreateDeployment); - }, - - canCreateEnvironmentParsed() { - return gl.utils.convertPermissionToBoolean(this.canCreateEnvironment); - }, - - }, - - /** - * Fetches all the environments and stores them. - * 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'); - }); - }, - - methods: { - toggleRow(model) { - return this.store.toggleFolder(model.name); - }, - - /** - * Will change the page number and update the URL. - * - * @param {Number} pageNumber desired page to go to. - * @return {String} - */ - changePage(pageNumber) { - const param = gl.utils.setParamInURL('page', pageNumber); - - gl.utils.visitUrl(param); - return param; - }, - }, - - template: ` -
    - - -
    -
    - -
    - -
    -

    - You don't have any environments right now. -

    -

    - Environments are places where code gets deployed, such as staging or production. -
    - - Read more about environments - -

    - - - New Environment - -
    - -
    - - -
    - - - -
    -
    - `, -}); diff --git a/app/assets/javascripts/environments/components/environment_actions.js b/app/assets/javascripts/environments/components/environment_actions.js new file mode 100644 index 00000000000..15e3f8823d2 --- /dev/null +++ b/app/assets/javascripts/environments/components/environment_actions.js @@ -0,0 +1,41 @@ +const Vue = require('vue'); +const playIconSvg = require('icons/_icon_play.svg'); + +module.exports = Vue.component('actions-component', { + props: { + actions: { + type: Array, + required: false, + default: () => [], + }, + }, + + data() { + return { playIconSvg }; + }, + + template: ` + + `, +}); diff --git a/app/assets/javascripts/environments/components/environment_actions.js.es6 b/app/assets/javascripts/environments/components/environment_actions.js.es6 deleted file mode 100644 index 15e3f8823d2..00000000000 --- a/app/assets/javascripts/environments/components/environment_actions.js.es6 +++ /dev/null @@ -1,41 +0,0 @@ -const Vue = require('vue'); -const playIconSvg = require('icons/_icon_play.svg'); - -module.exports = Vue.component('actions-component', { - props: { - actions: { - type: Array, - required: false, - default: () => [], - }, - }, - - data() { - return { playIconSvg }; - }, - - template: ` - - `, -}); diff --git a/app/assets/javascripts/environments/components/environment_external_url.js b/app/assets/javascripts/environments/components/environment_external_url.js new file mode 100644 index 00000000000..2599bba3c59 --- /dev/null +++ b/app/assets/javascripts/environments/components/environment_external_url.js @@ -0,0 +1,19 @@ +/** + * Renders the external url link in environments table. + */ +const Vue = require('vue'); + +module.exports = Vue.component('external-url-component', { + props: { + externalUrl: { + type: String, + default: '', + }, + }, + + template: ` + + + + `, +}); diff --git a/app/assets/javascripts/environments/components/environment_external_url.js.es6 b/app/assets/javascripts/environments/components/environment_external_url.js.es6 deleted file mode 100644 index 2599bba3c59..00000000000 --- a/app/assets/javascripts/environments/components/environment_external_url.js.es6 +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Renders the external url link in environments table. - */ -const Vue = require('vue'); - -module.exports = Vue.component('external-url-component', { - props: { - externalUrl: { - type: String, - default: '', - }, - }, - - template: ` - - - - `, -}); diff --git a/app/assets/javascripts/environments/components/environment_item.js b/app/assets/javascripts/environments/components/environment_item.js new file mode 100644 index 00000000000..7f4e070b229 --- /dev/null +++ b/app/assets/javascripts/environments/components/environment_item.js @@ -0,0 +1,510 @@ +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'); + +/** + * Envrionment Item Component + * + * Renders a table row for each environment. + */ + +const timeagoInstance = new Timeago(); + +module.exports = Vue.component('environment-item', { + + components: { + 'commit-component': gl.CommitComponent, + 'actions-component': ActionsComponent, + 'external-url-component': ExternalUrlComponent, + 'stop-component': StopComponent, + 'rollback-component': RollbackComponent, + 'terminal-button-component': TerminalButtonComponent, + }, + + props: { + model: { + type: Object, + required: true, + default: () => ({}), + }, + + canCreateDeployment: { + type: Boolean, + required: false, + default: false, + }, + + canReadEnvironment: { + type: Boolean, + required: false, + default: false, + }, + }, + + computed: { + /** + * Verifies if `last_deployment` key exists in the current Envrionment. + * This key is required to render most of the html - this method works has + * an helper. + * + * @returns {Boolean} + */ + hasLastDeploymentKey() { + if (this.model && + this.model.last_deployment && + !this.$options.isObjectEmpty(this.model.last_deployment)) { + return true; + } + return false; + }, + + /** + * Verifies is the given environment has manual actions. + * Used to verify if we should render them or nor. + * + * @returns {Boolean|Undefined} + */ + hasManualActions() { + return this.model && + this.model.last_deployment && + this.model.last_deployment.manual_actions && + this.model.last_deployment.manual_actions.length > 0; + }, + + /** + * Returns the value of the `stop_action?` key provided in the response. + * + * @returns {Boolean} + */ + hasStopAction() { + return this.model && this.model['stop_action?']; + }, + + /** + * Verifies if the `deployable` key is present in `last_deployment` key. + * Used to verify whether we should or not render the rollback partial. + * + * @returns {Boolean|Undefined} + */ + canRetry() { + return this.model && + this.hasLastDeploymentKey && + this.model.last_deployment && + this.model.last_deployment.deployable; + }, + + /** + * Verifies if the date to be shown is present. + * + * @returns {Boolean|Undefined} + */ + canShowDate() { + return this.model && + this.model.last_deployment && + this.model.last_deployment.deployable && + this.model.last_deployment.deployable !== undefined; + }, + + /** + * Human readable date. + * + * @returns {String} + */ + createdDate() { + if (this.model && + this.model.last_deployment && + this.model.last_deployment.deployable && + this.model.last_deployment.deployable.created_at) { + return timeagoInstance.format(this.model.last_deployment.deployable.created_at); + } + return ''; + }, + + /** + * Returns the manual actions with the name parsed. + * + * @returns {Array.|Undefined} + */ + manualActions() { + if (this.hasManualActions) { + return this.model.last_deployment.manual_actions.map((action) => { + const parsedAction = { + name: gl.text.humanize(action.name), + play_path: action.play_path, + }; + return parsedAction; + }); + } + return []; + }, + + /** + * Builds the string used in the user image alt attribute. + * + * @returns {String} + */ + userImageAltDescription() { + if (this.model && + this.model.last_deployment && + this.model.last_deployment.user && + this.model.last_deployment.user.username) { + return `${this.model.last_deployment.user.username}'s avatar'`; + } + return ''; + }, + + /** + * If provided, returns the commit tag. + * + * @returns {String|Undefined} + */ + commitTag() { + if (this.model && + this.model.last_deployment && + this.model.last_deployment.tag) { + return this.model.last_deployment.tag; + } + return undefined; + }, + + /** + * If provided, returns the commit ref. + * + * @returns {Object|Undefined} + */ + commitRef() { + if (this.model && + this.model.last_deployment && + this.model.last_deployment.ref) { + return this.model.last_deployment.ref; + } + return undefined; + }, + + /** + * If provided, returns the commit url. + * + * @returns {String|Undefined} + */ + commitUrl() { + if (this.model && + this.model.last_deployment && + this.model.last_deployment.commit && + this.model.last_deployment.commit.commit_path) { + return this.model.last_deployment.commit.commit_path; + } + return undefined; + }, + + /** + * If provided, returns the commit short sha. + * + * @returns {String|Undefined} + */ + commitShortSha() { + if (this.model && + this.model.last_deployment && + this.model.last_deployment.commit && + this.model.last_deployment.commit.short_id) { + return this.model.last_deployment.commit.short_id; + } + return undefined; + }, + + /** + * If provided, returns the commit title. + * + * @returns {String|Undefined} + */ + commitTitle() { + if (this.model && + this.model.last_deployment && + this.model.last_deployment.commit && + this.model.last_deployment.commit.title) { + return this.model.last_deployment.commit.title; + } + return undefined; + }, + + /** + * If provided, returns the commit tag. + * + * @returns {Object|Undefined} + */ + commitAuthor() { + if (this.model && + this.model.last_deployment && + this.model.last_deployment.commit && + this.model.last_deployment.commit.author) { + return this.model.last_deployment.commit.author; + } + + return undefined; + }, + + /** + * Verifies if the `retry_path` key is present and returns its value. + * + * @returns {String|Undefined} + */ + retryUrl() { + if (this.model && + this.model.last_deployment && + this.model.last_deployment.deployable && + this.model.last_deployment.deployable.retry_path) { + return this.model.last_deployment.deployable.retry_path; + } + return undefined; + }, + + /** + * Verifies if the `last?` key is present and returns its value. + * + * @returns {Boolean|Undefined} + */ + isLastDeployment() { + return this.model && this.model.last_deployment && + this.model.last_deployment['last?']; + }, + + /** + * Builds the name of the builds needed to display both the name and the id. + * + * @returns {String} + */ + buildName() { + if (this.model && + this.model.last_deployment && + this.model.last_deployment.deployable) { + return `${this.model.last_deployment.deployable.name} #${this.model.last_deployment.deployable.id}`; + } + return ''; + }, + + /** + * Builds the needed string to show the internal id. + * + * @returns {String} + */ + deploymentInternalId() { + if (this.model && + this.model.last_deployment && + this.model.last_deployment.iid) { + return `#${this.model.last_deployment.iid}`; + } + return ''; + }, + + /** + * Verifies if the user object is present under last_deployment object. + * + * @returns {Boolean} + */ + deploymentHasUser() { + return this.model && + !this.$options.isObjectEmpty(this.model.last_deployment) && + !this.$options.isObjectEmpty(this.model.last_deployment.user); + }, + + /** + * Returns the user object nested with the last_deployment object. + * Used to render the template. + * + * @returns {Object} + */ + deploymentUser() { + if (this.model && + !this.$options.isObjectEmpty(this.model.last_deployment) && + !this.$options.isObjectEmpty(this.model.last_deployment.user)) { + return this.model.last_deployment.user; + } + return {}; + }, + + /** + * Verifies if the build name column should be rendered by verifing + * if all the information needed is present + * and if the environment is not a folder. + * + * @returns {Boolean} + */ + shouldRenderBuildName() { + return !this.model.isFolder && + !this.$options.isObjectEmpty(this.model.last_deployment) && + !this.$options.isObjectEmpty(this.model.last_deployment.deployable); + }, + + /** + * Verifies the presence of all the keys needed to render the buil_path. + * + * @return {String} + */ + buildPath() { + if (this.model && + this.model.last_deployment && + this.model.last_deployment.deployable && + this.model.last_deployment.deployable.build_path) { + return this.model.last_deployment.deployable.build_path; + } + + return ''; + }, + + /** + * Verifies the presence of all the keys needed to render the external_url. + * + * @return {String} + */ + externalURL() { + if (this.model && this.model.external_url) { + return this.model.external_url; + } + + return ''; + }, + + /** + * Verifies if deplyment internal ID should be rendered by verifing + * if all the information needed is present + * and if the environment is not a folder. + * + * @returns {Boolean} + */ + shouldRenderDeploymentID() { + return !this.model.isFolder && + !this.$options.isObjectEmpty(this.model.last_deployment) && + this.model.last_deployment.iid !== undefined; + }, + + environmentPath() { + if (this.model && this.model.environment_path) { + return this.model.environment_path; + } + + return ''; + }, + + /** + * Constructs folder URL based on the current location and the folder id. + * + * @return {String} + */ + folderUrl() { + return `${window.location.pathname}/folders/${this.model.folderName}`; + }, + + }, + + /** + * Helper to verify if certain given object are empty. + * Should be replaced by lodash _.isEmpty - https://lodash.com/docs/4.17.2#isEmpty + * @param {Object} object + * @returns {Bollean} + */ + isObjectEmpty(object) { + for (const key in object) { // eslint-disable-line + if (hasOwnProperty.call(object, key)) { + return false; + } + } + return true; + }, + + template: ` + + + + {{model.name}} + + + + + + + + {{model.folderName}} + + + + {{model.size}} + + + + + + + {{deploymentInternalId}} + + + + by + + + + + + + + + {{buildName}} + + + + +
    + +
    +

    + No deployments yet +

    + + + + + {{createdDate}} + + + + +
    + + + + + + + + + +
    + + + `, +}); diff --git a/app/assets/javascripts/environments/components/environment_item.js.es6 b/app/assets/javascripts/environments/components/environment_item.js.es6 deleted file mode 100644 index 7f4e070b229..00000000000 --- a/app/assets/javascripts/environments/components/environment_item.js.es6 +++ /dev/null @@ -1,510 +0,0 @@ -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'); - -/** - * Envrionment Item Component - * - * Renders a table row for each environment. - */ - -const timeagoInstance = new Timeago(); - -module.exports = Vue.component('environment-item', { - - components: { - 'commit-component': gl.CommitComponent, - 'actions-component': ActionsComponent, - 'external-url-component': ExternalUrlComponent, - 'stop-component': StopComponent, - 'rollback-component': RollbackComponent, - 'terminal-button-component': TerminalButtonComponent, - }, - - props: { - model: { - type: Object, - required: true, - default: () => ({}), - }, - - canCreateDeployment: { - type: Boolean, - required: false, - default: false, - }, - - canReadEnvironment: { - type: Boolean, - required: false, - default: false, - }, - }, - - computed: { - /** - * Verifies if `last_deployment` key exists in the current Envrionment. - * This key is required to render most of the html - this method works has - * an helper. - * - * @returns {Boolean} - */ - hasLastDeploymentKey() { - if (this.model && - this.model.last_deployment && - !this.$options.isObjectEmpty(this.model.last_deployment)) { - return true; - } - return false; - }, - - /** - * Verifies is the given environment has manual actions. - * Used to verify if we should render them or nor. - * - * @returns {Boolean|Undefined} - */ - hasManualActions() { - return this.model && - this.model.last_deployment && - this.model.last_deployment.manual_actions && - this.model.last_deployment.manual_actions.length > 0; - }, - - /** - * Returns the value of the `stop_action?` key provided in the response. - * - * @returns {Boolean} - */ - hasStopAction() { - return this.model && this.model['stop_action?']; - }, - - /** - * Verifies if the `deployable` key is present in `last_deployment` key. - * Used to verify whether we should or not render the rollback partial. - * - * @returns {Boolean|Undefined} - */ - canRetry() { - return this.model && - this.hasLastDeploymentKey && - this.model.last_deployment && - this.model.last_deployment.deployable; - }, - - /** - * Verifies if the date to be shown is present. - * - * @returns {Boolean|Undefined} - */ - canShowDate() { - return this.model && - this.model.last_deployment && - this.model.last_deployment.deployable && - this.model.last_deployment.deployable !== undefined; - }, - - /** - * Human readable date. - * - * @returns {String} - */ - createdDate() { - if (this.model && - this.model.last_deployment && - this.model.last_deployment.deployable && - this.model.last_deployment.deployable.created_at) { - return timeagoInstance.format(this.model.last_deployment.deployable.created_at); - } - return ''; - }, - - /** - * Returns the manual actions with the name parsed. - * - * @returns {Array.|Undefined} - */ - manualActions() { - if (this.hasManualActions) { - return this.model.last_deployment.manual_actions.map((action) => { - const parsedAction = { - name: gl.text.humanize(action.name), - play_path: action.play_path, - }; - return parsedAction; - }); - } - return []; - }, - - /** - * Builds the string used in the user image alt attribute. - * - * @returns {String} - */ - userImageAltDescription() { - if (this.model && - this.model.last_deployment && - this.model.last_deployment.user && - this.model.last_deployment.user.username) { - return `${this.model.last_deployment.user.username}'s avatar'`; - } - return ''; - }, - - /** - * If provided, returns the commit tag. - * - * @returns {String|Undefined} - */ - commitTag() { - if (this.model && - this.model.last_deployment && - this.model.last_deployment.tag) { - return this.model.last_deployment.tag; - } - return undefined; - }, - - /** - * If provided, returns the commit ref. - * - * @returns {Object|Undefined} - */ - commitRef() { - if (this.model && - this.model.last_deployment && - this.model.last_deployment.ref) { - return this.model.last_deployment.ref; - } - return undefined; - }, - - /** - * If provided, returns the commit url. - * - * @returns {String|Undefined} - */ - commitUrl() { - if (this.model && - this.model.last_deployment && - this.model.last_deployment.commit && - this.model.last_deployment.commit.commit_path) { - return this.model.last_deployment.commit.commit_path; - } - return undefined; - }, - - /** - * If provided, returns the commit short sha. - * - * @returns {String|Undefined} - */ - commitShortSha() { - if (this.model && - this.model.last_deployment && - this.model.last_deployment.commit && - this.model.last_deployment.commit.short_id) { - return this.model.last_deployment.commit.short_id; - } - return undefined; - }, - - /** - * If provided, returns the commit title. - * - * @returns {String|Undefined} - */ - commitTitle() { - if (this.model && - this.model.last_deployment && - this.model.last_deployment.commit && - this.model.last_deployment.commit.title) { - return this.model.last_deployment.commit.title; - } - return undefined; - }, - - /** - * If provided, returns the commit tag. - * - * @returns {Object|Undefined} - */ - commitAuthor() { - if (this.model && - this.model.last_deployment && - this.model.last_deployment.commit && - this.model.last_deployment.commit.author) { - return this.model.last_deployment.commit.author; - } - - return undefined; - }, - - /** - * Verifies if the `retry_path` key is present and returns its value. - * - * @returns {String|Undefined} - */ - retryUrl() { - if (this.model && - this.model.last_deployment && - this.model.last_deployment.deployable && - this.model.last_deployment.deployable.retry_path) { - return this.model.last_deployment.deployable.retry_path; - } - return undefined; - }, - - /** - * Verifies if the `last?` key is present and returns its value. - * - * @returns {Boolean|Undefined} - */ - isLastDeployment() { - return this.model && this.model.last_deployment && - this.model.last_deployment['last?']; - }, - - /** - * Builds the name of the builds needed to display both the name and the id. - * - * @returns {String} - */ - buildName() { - if (this.model && - this.model.last_deployment && - this.model.last_deployment.deployable) { - return `${this.model.last_deployment.deployable.name} #${this.model.last_deployment.deployable.id}`; - } - return ''; - }, - - /** - * Builds the needed string to show the internal id. - * - * @returns {String} - */ - deploymentInternalId() { - if (this.model && - this.model.last_deployment && - this.model.last_deployment.iid) { - return `#${this.model.last_deployment.iid}`; - } - return ''; - }, - - /** - * Verifies if the user object is present under last_deployment object. - * - * @returns {Boolean} - */ - deploymentHasUser() { - return this.model && - !this.$options.isObjectEmpty(this.model.last_deployment) && - !this.$options.isObjectEmpty(this.model.last_deployment.user); - }, - - /** - * Returns the user object nested with the last_deployment object. - * Used to render the template. - * - * @returns {Object} - */ - deploymentUser() { - if (this.model && - !this.$options.isObjectEmpty(this.model.last_deployment) && - !this.$options.isObjectEmpty(this.model.last_deployment.user)) { - return this.model.last_deployment.user; - } - return {}; - }, - - /** - * Verifies if the build name column should be rendered by verifing - * if all the information needed is present - * and if the environment is not a folder. - * - * @returns {Boolean} - */ - shouldRenderBuildName() { - return !this.model.isFolder && - !this.$options.isObjectEmpty(this.model.last_deployment) && - !this.$options.isObjectEmpty(this.model.last_deployment.deployable); - }, - - /** - * Verifies the presence of all the keys needed to render the buil_path. - * - * @return {String} - */ - buildPath() { - if (this.model && - this.model.last_deployment && - this.model.last_deployment.deployable && - this.model.last_deployment.deployable.build_path) { - return this.model.last_deployment.deployable.build_path; - } - - return ''; - }, - - /** - * Verifies the presence of all the keys needed to render the external_url. - * - * @return {String} - */ - externalURL() { - if (this.model && this.model.external_url) { - return this.model.external_url; - } - - return ''; - }, - - /** - * Verifies if deplyment internal ID should be rendered by verifing - * if all the information needed is present - * and if the environment is not a folder. - * - * @returns {Boolean} - */ - shouldRenderDeploymentID() { - return !this.model.isFolder && - !this.$options.isObjectEmpty(this.model.last_deployment) && - this.model.last_deployment.iid !== undefined; - }, - - environmentPath() { - if (this.model && this.model.environment_path) { - return this.model.environment_path; - } - - return ''; - }, - - /** - * Constructs folder URL based on the current location and the folder id. - * - * @return {String} - */ - folderUrl() { - return `${window.location.pathname}/folders/${this.model.folderName}`; - }, - - }, - - /** - * Helper to verify if certain given object are empty. - * Should be replaced by lodash _.isEmpty - https://lodash.com/docs/4.17.2#isEmpty - * @param {Object} object - * @returns {Bollean} - */ - isObjectEmpty(object) { - for (const key in object) { // eslint-disable-line - if (hasOwnProperty.call(object, key)) { - return false; - } - } - return true; - }, - - template: ` - - - - {{model.name}} - - - - - - - - {{model.folderName}} - - - - {{model.size}} - - - - - - - {{deploymentInternalId}} - - - - by - - - - - - - - - {{buildName}} - - - - -
    - -
    -

    - No deployments yet -

    - - - - - {{createdDate}} - - - - -
    - - - - - - - - - -
    - - - `, -}); diff --git a/app/assets/javascripts/environments/components/environment_rollback.js b/app/assets/javascripts/environments/components/environment_rollback.js new file mode 100644 index 00000000000..daf126eb4e8 --- /dev/null +++ b/app/assets/javascripts/environments/components/environment_rollback.js @@ -0,0 +1,30 @@ +/** + * Renders Rollback or Re deploy button in environments table depending + * of the provided property `isLastDeployment` + */ +const Vue = require('vue'); + +module.exports = Vue.component('rollback-component', { + props: { + retryUrl: { + type: String, + default: '', + }, + + isLastDeployment: { + type: Boolean, + default: true, + }, + }, + + template: ` + + + Re-deploy + + + Rollback + + + `, +}); diff --git a/app/assets/javascripts/environments/components/environment_rollback.js.es6 b/app/assets/javascripts/environments/components/environment_rollback.js.es6 deleted file mode 100644 index daf126eb4e8..00000000000 --- a/app/assets/javascripts/environments/components/environment_rollback.js.es6 +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Renders Rollback or Re deploy button in environments table depending - * of the provided property `isLastDeployment` - */ -const Vue = require('vue'); - -module.exports = Vue.component('rollback-component', { - props: { - retryUrl: { - type: String, - default: '', - }, - - isLastDeployment: { - type: Boolean, - default: true, - }, - }, - - template: ` - - - Re-deploy - - - Rollback - - - `, -}); diff --git a/app/assets/javascripts/environments/components/environment_stop.js b/app/assets/javascripts/environments/components/environment_stop.js new file mode 100644 index 00000000000..96983a19568 --- /dev/null +++ b/app/assets/javascripts/environments/components/environment_stop.js @@ -0,0 +1,24 @@ +/** + * Renders the stop "button" that allows stop an environment. + * Used in environments table. + */ +const Vue = require('vue'); + +module.exports = Vue.component('stop-component', { + props: { + stopUrl: { + type: String, + default: '', + }, + }, + + template: ` + + + + `, +}); diff --git a/app/assets/javascripts/environments/components/environment_stop.js.es6 b/app/assets/javascripts/environments/components/environment_stop.js.es6 deleted file mode 100644 index 96983a19568..00000000000 --- a/app/assets/javascripts/environments/components/environment_stop.js.es6 +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Renders the stop "button" that allows stop an environment. - * Used in environments table. - */ -const Vue = require('vue'); - -module.exports = Vue.component('stop-component', { - props: { - stopUrl: { - type: String, - default: '', - }, - }, - - template: ` - - - - `, -}); diff --git a/app/assets/javascripts/environments/components/environment_terminal_button.js b/app/assets/javascripts/environments/components/environment_terminal_button.js new file mode 100644 index 00000000000..e86607e78f4 --- /dev/null +++ b/app/assets/javascripts/environments/components/environment_terminal_button.js @@ -0,0 +1,26 @@ +/** + * Renders a terminal button to open a web terminal. + * Used in environments table. + */ +const Vue = require('vue'); +const terminalIconSvg = require('icons/_icon_terminal.svg'); + +module.exports = Vue.component('terminal-button-component', { + props: { + terminalPath: { + type: String, + default: '', + }, + }, + + data() { + return { terminalIconSvg }; + }, + + template: ` + + ${terminalIconSvg} + + `, +}); diff --git a/app/assets/javascripts/environments/components/environment_terminal_button.js.es6 b/app/assets/javascripts/environments/components/environment_terminal_button.js.es6 deleted file mode 100644 index e86607e78f4..00000000000 --- a/app/assets/javascripts/environments/components/environment_terminal_button.js.es6 +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Renders a terminal button to open a web terminal. - * Used in environments table. - */ -const Vue = require('vue'); -const terminalIconSvg = require('icons/_icon_terminal.svg'); - -module.exports = Vue.component('terminal-button-component', { - props: { - terminalPath: { - type: String, - default: '', - }, - }, - - data() { - return { terminalIconSvg }; - }, - - template: ` - - ${terminalIconSvg} - - `, -}); diff --git a/app/assets/javascripts/environments/components/environments_table.js b/app/assets/javascripts/environments/components/environments_table.js new file mode 100644 index 00000000000..4088d63be80 --- /dev/null +++ b/app/assets/javascripts/environments/components/environments_table.js @@ -0,0 +1,56 @@ +/** + * Render environments table. + */ +const Vue = require('vue'); +const EnvironmentItem = require('./environment_item'); + +module.exports = Vue.component('environment-table-component', { + + components: { + 'environment-item': EnvironmentItem, + }, + + props: { + environments: { + type: Array, + required: true, + default: () => ([]), + }, + + canReadEnvironment: { + type: Boolean, + required: false, + default: false, + }, + + canCreateDeployment: { + type: Boolean, + required: false, + default: false, + }, + }, + + template: ` + + + + + + + + + + + + + + +
    EnvironmentLast deploymentJobCommitUpdated
    + `, +}); diff --git a/app/assets/javascripts/environments/components/environments_table.js.es6 b/app/assets/javascripts/environments/components/environments_table.js.es6 deleted file mode 100644 index 4088d63be80..00000000000 --- a/app/assets/javascripts/environments/components/environments_table.js.es6 +++ /dev/null @@ -1,56 +0,0 @@ -/** - * Render environments table. - */ -const Vue = require('vue'); -const EnvironmentItem = require('./environment_item'); - -module.exports = Vue.component('environment-table-component', { - - components: { - 'environment-item': EnvironmentItem, - }, - - props: { - environments: { - type: Array, - required: true, - default: () => ([]), - }, - - canReadEnvironment: { - type: Boolean, - required: false, - default: false, - }, - - canCreateDeployment: { - type: Boolean, - required: false, - default: false, - }, - }, - - template: ` - - - - - - - - - - - - - - -
    EnvironmentLast deploymentJobCommitUpdated
    - `, -}); diff --git a/app/assets/javascripts/environments/environments_bundle.js b/app/assets/javascripts/environments/environments_bundle.js new file mode 100644 index 00000000000..7bbba91bc10 --- /dev/null +++ b/app/assets/javascripts/environments/environments_bundle.js @@ -0,0 +1,13 @@ +const EnvironmentsComponent = require('./components/environment'); + +$(() => { + window.gl = window.gl || {}; + + if (gl.EnvironmentsListApp) { + gl.EnvironmentsListApp.$destroy(true); + } + + gl.EnvironmentsListApp = new EnvironmentsComponent({ + el: document.querySelector('#environments-list-view'), + }); +}); diff --git a/app/assets/javascripts/environments/environments_bundle.js.es6 b/app/assets/javascripts/environments/environments_bundle.js.es6 deleted file mode 100644 index 7bbba91bc10..00000000000 --- a/app/assets/javascripts/environments/environments_bundle.js.es6 +++ /dev/null @@ -1,13 +0,0 @@ -const EnvironmentsComponent = require('./components/environment'); - -$(() => { - window.gl = window.gl || {}; - - if (gl.EnvironmentsListApp) { - gl.EnvironmentsListApp.$destroy(true); - } - - gl.EnvironmentsListApp = new EnvironmentsComponent({ - el: document.querySelector('#environments-list-view'), - }); -}); diff --git a/app/assets/javascripts/environments/folder/environments_folder_bundle.js b/app/assets/javascripts/environments/folder/environments_folder_bundle.js new file mode 100644 index 00000000000..d2ca465351a --- /dev/null +++ b/app/assets/javascripts/environments/folder/environments_folder_bundle.js @@ -0,0 +1,13 @@ +const EnvironmentsFolderComponent = require('./environments_folder_view'); + +$(() => { + window.gl = window.gl || {}; + + if (gl.EnvironmentsListFolderApp) { + gl.EnvironmentsListFolderApp.$destroy(true); + } + + gl.EnvironmentsListFolderApp = new EnvironmentsFolderComponent({ + el: document.querySelector('#environments-folder-list-view'), + }); +}); diff --git a/app/assets/javascripts/environments/folder/environments_folder_bundle.js.es6 b/app/assets/javascripts/environments/folder/environments_folder_bundle.js.es6 deleted file mode 100644 index d2ca465351a..00000000000 --- a/app/assets/javascripts/environments/folder/environments_folder_bundle.js.es6 +++ /dev/null @@ -1,13 +0,0 @@ -const EnvironmentsFolderComponent = require('./environments_folder_view'); - -$(() => { - window.gl = window.gl || {}; - - if (gl.EnvironmentsListFolderApp) { - gl.EnvironmentsListFolderApp.$destroy(true); - } - - gl.EnvironmentsListFolderApp = new EnvironmentsFolderComponent({ - el: document.querySelector('#environments-folder-list-view'), - }); -}); diff --git a/app/assets/javascripts/environments/folder/environments_folder_view.js b/app/assets/javascripts/environments/folder/environments_folder_view.js new file mode 100644 index 00000000000..2a9d0492d7a --- /dev/null +++ b/app/assets/javascripts/environments/folder/environments_folder_view.js @@ -0,0 +1,182 @@ +/* eslint-disable no-param-reassign, no-new */ +/* global Flash */ + +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', { + + components: { + 'environment-table': EnvironmentTable, + 'table-pagination': gl.VueGlPagination, + }, + + data() { + const environmentsData = document.querySelector('#environments-folder-list-view').dataset; + const store = new EnvironmentsStore(); + const pathname = window.location.pathname; + const endpoint = `${pathname}.json`; + const folderName = pathname.substr(pathname.lastIndexOf('/') + 1); + + return { + store, + folderName, + endpoint, + state: store.state, + visibility: 'available', + isLoading: false, + cssContainerClass: environmentsData.cssClass, + canCreateDeployment: environmentsData.canCreateDeployment, + canReadEnvironment: environmentsData.canReadEnvironment, + + // svgs + commitIconSvg: environmentsData.commitIconSvg, + playIconSvg: environmentsData.playIconSvg, + terminalIconSvg: environmentsData.terminalIconSvg, + + // Pagination Properties, + paginationInformation: {}, + pageNumber: 1, + }; + }, + + computed: { + scope() { + return gl.utils.getParameterByName('scope'); + }, + + canReadEnvironmentParsed() { + return gl.utils.convertPermissionToBoolean(this.canReadEnvironment); + }, + + canCreateDeploymentParsed() { + return gl.utils.convertPermissionToBoolean(this.canCreateDeployment); + }, + + /** + * URL to link in the stopped tab. + * + * @return {String} + */ + stoppedPath() { + return `${window.location.pathname}?scope=stopped`; + }, + + /** + * URL to link in the available tab. + * + * @return {String} + */ + availablePath() { + return window.location.pathname; + }, + }, + + /** + * Fetches all the environments and stores them. + * 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'); + }); + }, + + methods: { + /** + * Will change the page number and update the URL. + * + * @param {Number} pageNumber desired page to go to. + */ + changePage(pageNumber) { + const param = gl.utils.setParamInURL('page', pageNumber); + + gl.utils.visitUrl(param); + return param; + }, + }, + + template: ` +
    + + +
    +
    + +
    + +
    + + + + + + +
    +
    +
    + `, +}); diff --git a/app/assets/javascripts/environments/folder/environments_folder_view.js.es6 b/app/assets/javascripts/environments/folder/environments_folder_view.js.es6 deleted file mode 100644 index 2a9d0492d7a..00000000000 --- a/app/assets/javascripts/environments/folder/environments_folder_view.js.es6 +++ /dev/null @@ -1,182 +0,0 @@ -/* eslint-disable no-param-reassign, no-new */ -/* global Flash */ - -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', { - - components: { - 'environment-table': EnvironmentTable, - 'table-pagination': gl.VueGlPagination, - }, - - data() { - const environmentsData = document.querySelector('#environments-folder-list-view').dataset; - const store = new EnvironmentsStore(); - const pathname = window.location.pathname; - const endpoint = `${pathname}.json`; - const folderName = pathname.substr(pathname.lastIndexOf('/') + 1); - - return { - store, - folderName, - endpoint, - state: store.state, - visibility: 'available', - isLoading: false, - cssContainerClass: environmentsData.cssClass, - canCreateDeployment: environmentsData.canCreateDeployment, - canReadEnvironment: environmentsData.canReadEnvironment, - - // svgs - commitIconSvg: environmentsData.commitIconSvg, - playIconSvg: environmentsData.playIconSvg, - terminalIconSvg: environmentsData.terminalIconSvg, - - // Pagination Properties, - paginationInformation: {}, - pageNumber: 1, - }; - }, - - computed: { - scope() { - return gl.utils.getParameterByName('scope'); - }, - - canReadEnvironmentParsed() { - return gl.utils.convertPermissionToBoolean(this.canReadEnvironment); - }, - - canCreateDeploymentParsed() { - return gl.utils.convertPermissionToBoolean(this.canCreateDeployment); - }, - - /** - * URL to link in the stopped tab. - * - * @return {String} - */ - stoppedPath() { - return `${window.location.pathname}?scope=stopped`; - }, - - /** - * URL to link in the available tab. - * - * @return {String} - */ - availablePath() { - return window.location.pathname; - }, - }, - - /** - * Fetches all the environments and stores them. - * 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'); - }); - }, - - methods: { - /** - * Will change the page number and update the URL. - * - * @param {Number} pageNumber desired page to go to. - */ - changePage(pageNumber) { - const param = gl.utils.setParamInURL('page', pageNumber); - - gl.utils.visitUrl(param); - return param; - }, - }, - - template: ` -
    - - -
    -
    - -
    - -
    - - - - - - -
    -
    -
    - `, -}); diff --git a/app/assets/javascripts/environments/services/environments_service.js b/app/assets/javascripts/environments/services/environments_service.js new file mode 100644 index 00000000000..effc6c4c838 --- /dev/null +++ b/app/assets/javascripts/environments/services/environments_service.js @@ -0,0 +1,13 @@ +const Vue = require('vue'); + +class EnvironmentsService { + constructor(endpoint) { + this.environments = Vue.resource(endpoint); + } + + get() { + return this.environments.get(); + } +} + +module.exports = EnvironmentsService; diff --git a/app/assets/javascripts/environments/services/environments_service.js.es6 b/app/assets/javascripts/environments/services/environments_service.js.es6 deleted file mode 100644 index effc6c4c838..00000000000 --- a/app/assets/javascripts/environments/services/environments_service.js.es6 +++ /dev/null @@ -1,13 +0,0 @@ -const Vue = require('vue'); - -class EnvironmentsService { - constructor(endpoint) { - this.environments = Vue.resource(endpoint); - } - - get() { - return this.environments.get(); - } -} - -module.exports = EnvironmentsService; diff --git a/app/assets/javascripts/environments/stores/environments_store.js b/app/assets/javascripts/environments/stores/environments_store.js new file mode 100644 index 00000000000..15cd9bde08e --- /dev/null +++ b/app/assets/javascripts/environments/stores/environments_store.js @@ -0,0 +1,90 @@ +require('~/lib/utils/common_utils'); +/** + * Environments Store. + * + * Stores received environments, count of stopped environments and count of + * available environments. + */ +class EnvironmentsStore { + constructor() { + this.state = {}; + this.state.environments = []; + this.state.stoppedCounter = 0; + this.state.availableCounter = 0; + this.state.paginationInformation = {}; + + return this; + } + + /** + * + * Stores the received environments. + * + * In the main environments endpoint, each environment has the following schema + * { name: String, size: Number, latest: Object } + * In the endpoint to retrieve environments from each folder, the environment does + * not have the `latest` key and the data is all in the root level. + * To avoid doing this check in the view, we store both cases the same by extracting + * what is inside the `latest` key. + * + * If the `size` is bigger than 1, it means it should be rendered as a folder. + * In those cases we add `isFolder` key in order to render it properly. + * + * @param {Array} environments + * @returns {Array} + */ + storeEnvironments(environments = []) { + const filteredEnvironments = environments.map((env) => { + let filtered = {}; + + if (env.size > 1) { + filtered = Object.assign({}, env, { isFolder: true, folderName: env.name }); + } + + if (env.latest) { + filtered = Object.assign(filtered, env, env.latest); + delete filtered.latest; + } else { + filtered = Object.assign(filtered, env); + } + + return filtered; + }); + + this.state.environments = filteredEnvironments; + + return filteredEnvironments; + } + + setPagination(pagination = {}) { + const normalizedHeaders = gl.utils.normalizeHeaders(pagination); + const paginationInformation = gl.utils.parseIntPagination(normalizedHeaders); + + this.state.paginationInformation = paginationInformation; + return paginationInformation; + } + + /** + * Stores the number of available environments. + * + * @param {Number} count = 0 + * @return {Number} + */ + storeAvailableCount(count = 0) { + this.state.availableCounter = count; + return count; + } + + /** + * Stores the number of closed environments. + * + * @param {Number} count = 0 + * @return {Number} + */ + storeStoppedCount(count = 0) { + this.state.stoppedCounter = count; + return count; + } +} + +module.exports = EnvironmentsStore; diff --git a/app/assets/javascripts/environments/stores/environments_store.js.es6 b/app/assets/javascripts/environments/stores/environments_store.js.es6 deleted file mode 100644 index 15cd9bde08e..00000000000 --- a/app/assets/javascripts/environments/stores/environments_store.js.es6 +++ /dev/null @@ -1,90 +0,0 @@ -require('~/lib/utils/common_utils'); -/** - * Environments Store. - * - * Stores received environments, count of stopped environments and count of - * available environments. - */ -class EnvironmentsStore { - constructor() { - this.state = {}; - this.state.environments = []; - this.state.stoppedCounter = 0; - this.state.availableCounter = 0; - this.state.paginationInformation = {}; - - return this; - } - - /** - * - * Stores the received environments. - * - * In the main environments endpoint, each environment has the following schema - * { name: String, size: Number, latest: Object } - * In the endpoint to retrieve environments from each folder, the environment does - * not have the `latest` key and the data is all in the root level. - * To avoid doing this check in the view, we store both cases the same by extracting - * what is inside the `latest` key. - * - * If the `size` is bigger than 1, it means it should be rendered as a folder. - * In those cases we add `isFolder` key in order to render it properly. - * - * @param {Array} environments - * @returns {Array} - */ - storeEnvironments(environments = []) { - const filteredEnvironments = environments.map((env) => { - let filtered = {}; - - if (env.size > 1) { - filtered = Object.assign({}, env, { isFolder: true, folderName: env.name }); - } - - if (env.latest) { - filtered = Object.assign(filtered, env, env.latest); - delete filtered.latest; - } else { - filtered = Object.assign(filtered, env); - } - - return filtered; - }); - - this.state.environments = filteredEnvironments; - - return filteredEnvironments; - } - - setPagination(pagination = {}) { - const normalizedHeaders = gl.utils.normalizeHeaders(pagination); - const paginationInformation = gl.utils.parseIntPagination(normalizedHeaders); - - this.state.paginationInformation = paginationInformation; - return paginationInformation; - } - - /** - * Stores the number of available environments. - * - * @param {Number} count = 0 - * @return {Number} - */ - storeAvailableCount(count = 0) { - this.state.availableCounter = count; - return count; - } - - /** - * Stores the number of closed environments. - * - * @param {Number} count = 0 - * @return {Number} - */ - storeStoppedCount(count = 0) { - this.state.stoppedCounter = count; - return count; - } -} - -module.exports = EnvironmentsStore; diff --git a/app/assets/javascripts/extensions/array.js b/app/assets/javascripts/extensions/array.js new file mode 100644 index 00000000000..f8256a8d26d --- /dev/null +++ b/app/assets/javascripts/extensions/array.js @@ -0,0 +1,27 @@ +/* eslint-disable no-extend-native, func-names, space-before-function-paren, space-infix-ops, strict, max-len */ + +'use strict'; + +Array.prototype.first = function() { + 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; +}; diff --git a/app/assets/javascripts/extensions/array.js.es6 b/app/assets/javascripts/extensions/array.js.es6 deleted file mode 100644 index f8256a8d26d..00000000000 --- a/app/assets/javascripts/extensions/array.js.es6 +++ /dev/null @@ -1,27 +0,0 @@ -/* eslint-disable no-extend-native, func-names, space-before-function-paren, space-infix-ops, strict, max-len */ - -'use strict'; - -Array.prototype.first = function() { - 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; -}; diff --git a/app/assets/javascripts/extensions/custom_event.js b/app/assets/javascripts/extensions/custom_event.js new file mode 100644 index 00000000000..abedae4c1c7 --- /dev/null +++ b/app/assets/javascripts/extensions/custom_event.js @@ -0,0 +1,12 @@ +/* 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/custom_event.js.es6 b/app/assets/javascripts/extensions/custom_event.js.es6 deleted file mode 100644 index abedae4c1c7..00000000000 --- a/app/assets/javascripts/extensions/custom_event.js.es6 +++ /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 new file mode 100644 index 00000000000..90ab79305a7 --- /dev/null +++ b/app/assets/javascripts/extensions/element.js @@ -0,0 +1,20 @@ +/* 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/element.js.es6 b/app/assets/javascripts/extensions/element.js.es6 deleted file mode 100644 index 90ab79305a7..00000000000 --- a/app/assets/javascripts/extensions/element.js.es6 +++ /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/object.js b/app/assets/javascripts/extensions/object.js new file mode 100644 index 00000000000..70a2d765abd --- /dev/null +++ b/app/assets/javascripts/extensions/object.js @@ -0,0 +1,26 @@ +/* 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/object.js.es6 b/app/assets/javascripts/extensions/object.js.es6 deleted file mode 100644 index 70a2d765abd..00000000000 --- a/app/assets/javascripts/extensions/object.js.es6 +++ /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/filtered_search/dropdown_hint.js b/app/assets/javascripts/filtered_search/dropdown_hint.js new file mode 100644 index 00000000000..9e92d544bef --- /dev/null +++ b/app/assets/javascripts/filtered_search/dropdown_hint.js @@ -0,0 +1,64 @@ +require('./filtered_search_dropdown'); + +/* global droplabFilter */ + +(() => { + class DropdownHint extends gl.FilteredSearchDropdown { + constructor(droplab, dropdown, input, filter) { + super(droplab, dropdown, input, filter); + this.config = { + droplabFilter: { + template: 'hint', + filterFunction: gl.DropdownUtils.filterHint.bind(null, input), + }, + }; + } + + itemClicked(e) { + const { selected } = e.detail; + + if (selected.tagName === 'LI') { + if (selected.hasAttribute('data-value')) { + this.dismissDropdown(); + } else if (selected.getAttribute('data-action') === 'submit') { + this.dismissDropdown(); + this.dispatchFormSubmitEvent(); + } else { + const token = selected.querySelector('.js-filter-hint').innerText.trim(); + const tag = selected.querySelector('.js-filter-tag').innerText.trim(); + + if (tag.length) { + gl.FilteredSearchDropdownManager.addWordToInput(token.replace(':', '')); + } + this.dismissDropdown(); + this.dispatchInputEvent(); + } + } + } + + renderContent() { + const dropdownData = []; + + [].forEach.call(this.input.parentElement.querySelectorAll('.dropdown-menu'), (dropdownMenu) => { + const { icon, hint, tag } = dropdownMenu.dataset; + if (icon && hint && tag) { + dropdownData.push({ + icon: `fa-${icon}`, + hint, + tag: `<${tag}>`, + }); + } + }); + + this.droplab.changeHookList(this.hookId, this.dropdown, [droplabFilter], this.config); + this.droplab.setData(this.hookId, dropdownData); + } + + init() { + this.droplab.addHook(this.input, this.dropdown, [droplabFilter], this.config).init(); + } + } + + window.gl = window.gl || {}; + gl.DropdownHint = DropdownHint; +})(); diff --git a/app/assets/javascripts/filtered_search/dropdown_hint.js.es6 b/app/assets/javascripts/filtered_search/dropdown_hint.js.es6 deleted file mode 100644 index 9e92d544bef..00000000000 --- a/app/assets/javascripts/filtered_search/dropdown_hint.js.es6 +++ /dev/null @@ -1,64 +0,0 @@ -require('./filtered_search_dropdown'); - -/* global droplabFilter */ - -(() => { - class DropdownHint extends gl.FilteredSearchDropdown { - constructor(droplab, dropdown, input, filter) { - super(droplab, dropdown, input, filter); - this.config = { - droplabFilter: { - template: 'hint', - filterFunction: gl.DropdownUtils.filterHint.bind(null, input), - }, - }; - } - - itemClicked(e) { - const { selected } = e.detail; - - if (selected.tagName === 'LI') { - if (selected.hasAttribute('data-value')) { - this.dismissDropdown(); - } else if (selected.getAttribute('data-action') === 'submit') { - this.dismissDropdown(); - this.dispatchFormSubmitEvent(); - } else { - const token = selected.querySelector('.js-filter-hint').innerText.trim(); - const tag = selected.querySelector('.js-filter-tag').innerText.trim(); - - if (tag.length) { - gl.FilteredSearchDropdownManager.addWordToInput(token.replace(':', '')); - } - this.dismissDropdown(); - this.dispatchInputEvent(); - } - } - } - - renderContent() { - const dropdownData = []; - - [].forEach.call(this.input.parentElement.querySelectorAll('.dropdown-menu'), (dropdownMenu) => { - const { icon, hint, tag } = dropdownMenu.dataset; - if (icon && hint && tag) { - dropdownData.push({ - icon: `fa-${icon}`, - hint, - tag: `<${tag}>`, - }); - } - }); - - this.droplab.changeHookList(this.hookId, this.dropdown, [droplabFilter], this.config); - this.droplab.setData(this.hookId, dropdownData); - } - - init() { - this.droplab.addHook(this.input, this.dropdown, [droplabFilter], this.config).init(); - } - } - - window.gl = window.gl || {}; - gl.DropdownHint = DropdownHint; -})(); diff --git a/app/assets/javascripts/filtered_search/dropdown_non_user.js b/app/assets/javascripts/filtered_search/dropdown_non_user.js new file mode 100644 index 00000000000..b3dc3e502c5 --- /dev/null +++ b/app/assets/javascripts/filtered_search/dropdown_non_user.js @@ -0,0 +1,44 @@ +require('./filtered_search_dropdown'); + +/* global droplabAjax */ +/* global droplabFilter */ + +(() => { + class DropdownNonUser extends gl.FilteredSearchDropdown { + constructor(droplab, dropdown, input, filter, endpoint, symbol) { + super(droplab, dropdown, input, filter); + this.symbol = symbol; + this.config = { + droplabAjax: { + endpoint, + method: 'setData', + loadingTemplate: this.loadingTemplate, + }, + droplabFilter: { + filterFunction: gl.DropdownUtils.filterWithSymbol.bind(null, this.symbol, input), + }, + }; + } + + itemClicked(e) { + super.itemClicked(e, (selected) => { + const title = selected.querySelector('.js-data-value').innerText.trim(); + return `${this.symbol}${gl.DropdownUtils.getEscapedText(title)}`; + }); + } + + renderContent(forceShowList = false) { + this.droplab + .changeHookList(this.hookId, this.dropdown, [droplabAjax, droplabFilter], this.config); + super.renderContent(forceShowList); + } + + init() { + this.droplab + .addHook(this.input, this.dropdown, [droplabAjax, droplabFilter], this.config).init(); + } + } + + window.gl = window.gl || {}; + gl.DropdownNonUser = DropdownNonUser; +})(); diff --git a/app/assets/javascripts/filtered_search/dropdown_non_user.js.es6 b/app/assets/javascripts/filtered_search/dropdown_non_user.js.es6 deleted file mode 100644 index b3dc3e502c5..00000000000 --- a/app/assets/javascripts/filtered_search/dropdown_non_user.js.es6 +++ /dev/null @@ -1,44 +0,0 @@ -require('./filtered_search_dropdown'); - -/* global droplabAjax */ -/* global droplabFilter */ - -(() => { - class DropdownNonUser extends gl.FilteredSearchDropdown { - constructor(droplab, dropdown, input, filter, endpoint, symbol) { - super(droplab, dropdown, input, filter); - this.symbol = symbol; - this.config = { - droplabAjax: { - endpoint, - method: 'setData', - loadingTemplate: this.loadingTemplate, - }, - droplabFilter: { - filterFunction: gl.DropdownUtils.filterWithSymbol.bind(null, this.symbol, input), - }, - }; - } - - itemClicked(e) { - super.itemClicked(e, (selected) => { - const title = selected.querySelector('.js-data-value').innerText.trim(); - return `${this.symbol}${gl.DropdownUtils.getEscapedText(title)}`; - }); - } - - renderContent(forceShowList = false) { - this.droplab - .changeHookList(this.hookId, this.dropdown, [droplabAjax, droplabFilter], this.config); - super.renderContent(forceShowList); - } - - init() { - this.droplab - .addHook(this.input, this.dropdown, [droplabAjax, droplabFilter], this.config).init(); - } - } - - window.gl = window.gl || {}; - gl.DropdownNonUser = DropdownNonUser; -})(); diff --git a/app/assets/javascripts/filtered_search/dropdown_user.js b/app/assets/javascripts/filtered_search/dropdown_user.js new file mode 100644 index 00000000000..7e9c6f74aa5 --- /dev/null +++ b/app/assets/javascripts/filtered_search/dropdown_user.js @@ -0,0 +1,60 @@ +require('./filtered_search_dropdown'); + +/* global droplabAjaxFilter */ + +(() => { + class DropdownUser extends gl.FilteredSearchDropdown { + constructor(droplab, dropdown, input, filter) { + super(droplab, dropdown, input, filter); + this.config = { + droplabAjaxFilter: { + endpoint: `${gon.relative_url_root || ''}/autocomplete/users.json`, + searchKey: 'search', + params: { + per_page: 20, + active: true, + project_id: this.getProjectId(), + current_user: true, + }, + searchValueFunction: this.getSearchInput.bind(this), + loadingTemplate: this.loadingTemplate, + }, + }; + } + + itemClicked(e) { + super.itemClicked(e, + selected => selected.querySelector('.dropdown-light-content').innerText.trim()); + } + + renderContent(forceShowList = false) { + this.droplab.changeHookList(this.hookId, this.dropdown, [droplabAjaxFilter], this.config); + super.renderContent(forceShowList); + } + + getProjectId() { + return this.input.getAttribute('data-project-id'); + } + + getSearchInput() { + const query = gl.DropdownUtils.getSearchInput(this.input); + const { lastToken } = gl.FilteredSearchTokenizer.processTokens(query); + let value = lastToken.value || ''; + + // Removes the first character if it is a quotation so that we can search + // with multiple words + if (value[0] === '"' || value[0] === '\'') { + value = value.slice(1); + } + + return value; + } + + init() { + this.droplab.addHook(this.input, this.dropdown, [droplabAjaxFilter], this.config).init(); + } + } + + window.gl = window.gl || {}; + gl.DropdownUser = DropdownUser; +})(); diff --git a/app/assets/javascripts/filtered_search/dropdown_user.js.es6 b/app/assets/javascripts/filtered_search/dropdown_user.js.es6 deleted file mode 100644 index 7e9c6f74aa5..00000000000 --- a/app/assets/javascripts/filtered_search/dropdown_user.js.es6 +++ /dev/null @@ -1,60 +0,0 @@ -require('./filtered_search_dropdown'); - -/* global droplabAjaxFilter */ - -(() => { - class DropdownUser extends gl.FilteredSearchDropdown { - constructor(droplab, dropdown, input, filter) { - super(droplab, dropdown, input, filter); - this.config = { - droplabAjaxFilter: { - endpoint: `${gon.relative_url_root || ''}/autocomplete/users.json`, - searchKey: 'search', - params: { - per_page: 20, - active: true, - project_id: this.getProjectId(), - current_user: true, - }, - searchValueFunction: this.getSearchInput.bind(this), - loadingTemplate: this.loadingTemplate, - }, - }; - } - - itemClicked(e) { - super.itemClicked(e, - selected => selected.querySelector('.dropdown-light-content').innerText.trim()); - } - - renderContent(forceShowList = false) { - this.droplab.changeHookList(this.hookId, this.dropdown, [droplabAjaxFilter], this.config); - super.renderContent(forceShowList); - } - - getProjectId() { - return this.input.getAttribute('data-project-id'); - } - - getSearchInput() { - const query = gl.DropdownUtils.getSearchInput(this.input); - const { lastToken } = gl.FilteredSearchTokenizer.processTokens(query); - let value = lastToken.value || ''; - - // Removes the first character if it is a quotation so that we can search - // with multiple words - if (value[0] === '"' || value[0] === '\'') { - value = value.slice(1); - } - - return value; - } - - init() { - this.droplab.addHook(this.input, this.dropdown, [droplabAjaxFilter], this.config).init(); - } - } - - window.gl = window.gl || {}; - gl.DropdownUser = DropdownUser; -})(); diff --git a/app/assets/javascripts/filtered_search/dropdown_utils.js b/app/assets/javascripts/filtered_search/dropdown_utils.js new file mode 100644 index 00000000000..de3fa116717 --- /dev/null +++ b/app/assets/javascripts/filtered_search/dropdown_utils.js @@ -0,0 +1,126 @@ +(() => { + class DropdownUtils { + static getEscapedText(text) { + let escapedText = text; + const hasSpace = text.indexOf(' ') !== -1; + const hasDoubleQuote = text.indexOf('"') !== -1; + + // Encapsulate value with quotes if it has spaces + // Known side effect: values's with both single and double quotes + // won't escape properly + if (hasSpace) { + if (hasDoubleQuote) { + escapedText = `'${text}'`; + } else { + // Encapsulate singleQuotes or if it hasSpace + escapedText = `"${text}"`; + } + } + + return escapedText; + } + + static filterWithSymbol(filterSymbol, input, item) { + const updatedItem = item; + const query = gl.DropdownUtils.getSearchInput(input); + const { lastToken, searchToken } = gl.FilteredSearchTokenizer.processTokens(query); + + if (lastToken !== searchToken) { + const title = updatedItem.title.toLowerCase(); + let value = lastToken.value.toLowerCase(); + + // Removes the first character if it is a quotation so that we can search + // with multiple words + if ((value[0] === '"' || value[0] === '\'') && title.indexOf(' ') !== -1) { + value = value.slice(1); + } + + // Eg. filterSymbol = ~ for labels + const matchWithoutSymbol = lastToken.symbol === filterSymbol && title.indexOf(value) !== -1; + const match = title.indexOf(`${lastToken.symbol}${value}`) !== -1; + + updatedItem.droplab_hidden = !match && !matchWithoutSymbol; + } else { + updatedItem.droplab_hidden = false; + } + + return updatedItem; + } + + static filterHint(input, item) { + const updatedItem = item; + const query = gl.DropdownUtils.getSearchInput(input); + let { lastToken } = gl.FilteredSearchTokenizer.processTokens(query); + lastToken = lastToken.key || lastToken || ''; + + if (!lastToken || query.split('').last() === ' ') { + updatedItem.droplab_hidden = false; + } else if (lastToken) { + const split = lastToken.split(':'); + const tokenName = split[0].split(' ').last(); + + const match = updatedItem.hint.indexOf(tokenName.toLowerCase()) === -1; + updatedItem.droplab_hidden = tokenName ? match : false; + } + + return updatedItem; + } + + static setDataValueIfSelected(filter, selected) { + const dataValue = selected.getAttribute('data-value'); + + if (dataValue) { + gl.FilteredSearchDropdownManager.addWordToInput(filter, dataValue); + } + + // Return boolean based on whether it was set + return dataValue !== null; + } + + static getSearchInput(filteredSearchInput) { + const inputValue = filteredSearchInput.value; + const { right } = gl.DropdownUtils.getInputSelectionPosition(filteredSearchInput); + + return inputValue.slice(0, right); + } + + static getInputSelectionPosition(input) { + const selectionStart = input.selectionStart; + let inputValue = input.value; + // Replace all spaces inside quote marks with underscores + // (will continue to match entire string until an end quote is found if any) + // This helps with matching the beginning & end of a token:key + inputValue = inputValue.replace(/(('[^']*'{0,1})|("[^"]*"{0,1})|:\s+)/g, str => str.replace(/\s/g, '_')); + + // Get the right position for the word selected + // Regex matches first space + let right = inputValue.slice(selectionStart).search(/\s/); + + if (right >= 0) { + right += selectionStart; + } else if (right < 0) { + right = inputValue.length; + } + + // Get the left position for the word selected + // Regex matches last non-whitespace character + let left = inputValue.slice(0, right).search(/\S+$/); + + if (selectionStart === 0) { + left = 0; + } else if (selectionStart === inputValue.length && left < 0) { + left = inputValue.length; + } else if (left < 0) { + left = selectionStart; + } + + return { + left, + right, + }; + } + } + + window.gl = window.gl || {}; + gl.DropdownUtils = DropdownUtils; +})(); diff --git a/app/assets/javascripts/filtered_search/dropdown_utils.js.es6 b/app/assets/javascripts/filtered_search/dropdown_utils.js.es6 deleted file mode 100644 index de3fa116717..00000000000 --- a/app/assets/javascripts/filtered_search/dropdown_utils.js.es6 +++ /dev/null @@ -1,126 +0,0 @@ -(() => { - class DropdownUtils { - static getEscapedText(text) { - let escapedText = text; - const hasSpace = text.indexOf(' ') !== -1; - const hasDoubleQuote = text.indexOf('"') !== -1; - - // Encapsulate value with quotes if it has spaces - // Known side effect: values's with both single and double quotes - // won't escape properly - if (hasSpace) { - if (hasDoubleQuote) { - escapedText = `'${text}'`; - } else { - // Encapsulate singleQuotes or if it hasSpace - escapedText = `"${text}"`; - } - } - - return escapedText; - } - - static filterWithSymbol(filterSymbol, input, item) { - const updatedItem = item; - const query = gl.DropdownUtils.getSearchInput(input); - const { lastToken, searchToken } = gl.FilteredSearchTokenizer.processTokens(query); - - if (lastToken !== searchToken) { - const title = updatedItem.title.toLowerCase(); - let value = lastToken.value.toLowerCase(); - - // Removes the first character if it is a quotation so that we can search - // with multiple words - if ((value[0] === '"' || value[0] === '\'') && title.indexOf(' ') !== -1) { - value = value.slice(1); - } - - // Eg. filterSymbol = ~ for labels - const matchWithoutSymbol = lastToken.symbol === filterSymbol && title.indexOf(value) !== -1; - const match = title.indexOf(`${lastToken.symbol}${value}`) !== -1; - - updatedItem.droplab_hidden = !match && !matchWithoutSymbol; - } else { - updatedItem.droplab_hidden = false; - } - - return updatedItem; - } - - static filterHint(input, item) { - const updatedItem = item; - const query = gl.DropdownUtils.getSearchInput(input); - let { lastToken } = gl.FilteredSearchTokenizer.processTokens(query); - lastToken = lastToken.key || lastToken || ''; - - if (!lastToken || query.split('').last() === ' ') { - updatedItem.droplab_hidden = false; - } else if (lastToken) { - const split = lastToken.split(':'); - const tokenName = split[0].split(' ').last(); - - const match = updatedItem.hint.indexOf(tokenName.toLowerCase()) === -1; - updatedItem.droplab_hidden = tokenName ? match : false; - } - - return updatedItem; - } - - static setDataValueIfSelected(filter, selected) { - const dataValue = selected.getAttribute('data-value'); - - if (dataValue) { - gl.FilteredSearchDropdownManager.addWordToInput(filter, dataValue); - } - - // Return boolean based on whether it was set - return dataValue !== null; - } - - static getSearchInput(filteredSearchInput) { - const inputValue = filteredSearchInput.value; - const { right } = gl.DropdownUtils.getInputSelectionPosition(filteredSearchInput); - - return inputValue.slice(0, right); - } - - static getInputSelectionPosition(input) { - const selectionStart = input.selectionStart; - let inputValue = input.value; - // Replace all spaces inside quote marks with underscores - // (will continue to match entire string until an end quote is found if any) - // This helps with matching the beginning & end of a token:key - inputValue = inputValue.replace(/(('[^']*'{0,1})|("[^"]*"{0,1})|:\s+)/g, str => str.replace(/\s/g, '_')); - - // Get the right position for the word selected - // Regex matches first space - let right = inputValue.slice(selectionStart).search(/\s/); - - if (right >= 0) { - right += selectionStart; - } else if (right < 0) { - right = inputValue.length; - } - - // Get the left position for the word selected - // Regex matches last non-whitespace character - let left = inputValue.slice(0, right).search(/\S+$/); - - if (selectionStart === 0) { - left = 0; - } else if (selectionStart === inputValue.length && left < 0) { - left = inputValue.length; - } else if (left < 0) { - left = selectionStart; - } - - return { - left, - right, - }; - } - } - - window.gl = window.gl || {}; - gl.DropdownUtils = DropdownUtils; -})(); diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown.js new file mode 100644 index 00000000000..dd565da507e --- /dev/null +++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown.js @@ -0,0 +1,123 @@ +(() => { + const DATA_DROPDOWN_TRIGGER = 'data-dropdown-trigger'; + + class FilteredSearchDropdown { + constructor(droplab, dropdown, input, filter) { + this.droplab = droplab; + this.hookId = input && input.getAttribute('data-id'); + this.input = input; + this.filter = filter; + this.dropdown = dropdown; + this.loadingTemplate = `
    + +
    `; + this.bindEvents(); + } + + bindEvents() { + this.itemClickedWrapper = this.itemClicked.bind(this); + this.dropdown.addEventListener('click.dl', this.itemClickedWrapper); + } + + unbindEvents() { + this.dropdown.removeEventListener('click.dl', this.itemClickedWrapper); + } + + getCurrentHook() { + return this.droplab.hooks.filter(h => h.id === this.hookId)[0] || null; + } + + itemClicked(e, getValueFunction) { + const { selected } = e.detail; + + if (selected.tagName === 'LI' && selected.innerHTML) { + const dataValueSet = gl.DropdownUtils.setDataValueIfSelected(this.filter, selected); + + if (!dataValueSet) { + const value = getValueFunction(selected); + gl.FilteredSearchDropdownManager.addWordToInput(this.filter, value); + } + + this.dismissDropdown(); + this.dispatchInputEvent(); + } + } + + setAsDropdown() { + this.input.setAttribute(DATA_DROPDOWN_TRIGGER, `#${this.dropdown.id}`); + } + + setOffset(offset = 0) { + if (window.innerWidth > 480) { + this.dropdown.style.left = `${offset}px`; + } else { + this.dropdown.style.left = '0px'; + } + } + + renderContent(forceShowList = false) { + const currentHook = this.getCurrentHook(); + if (forceShowList && currentHook && currentHook.list.hidden) { + currentHook.list.show(); + } + } + + render(forceRenderContent = false, forceShowList = false) { + this.setAsDropdown(); + + const currentHook = this.getCurrentHook(); + const firstTimeInitialized = currentHook === null; + + if (firstTimeInitialized || forceRenderContent) { + this.renderContent(forceShowList); + } else if (currentHook.list.list.id !== this.dropdown.id) { + this.renderContent(forceShowList); + } + } + + dismissDropdown() { + // Focusing on the input will dismiss dropdown + // (default droplab functionality) + this.input.focus(); + } + + dispatchInputEvent() { + // Propogate input change to FilteredSearchDropdownManager + // so that it can determine which dropdowns to open + this.input.dispatchEvent(new CustomEvent('input', { + bubbles: true, + cancelable: true, + })); + } + + dispatchFormSubmitEvent() { + // dispatchEvent() is necessary as form.submit() does not + // trigger event handlers + this.input.form.dispatchEvent(new Event('submit')); + } + + hideDropdown() { + const currentHook = this.getCurrentHook(); + if (currentHook) { + currentHook.list.hide(); + } + } + + resetFilters() { + const hook = this.getCurrentHook(); + + if (hook) { + const data = hook.list.data; + const results = data.map((o) => { + const updated = o; + updated.droplab_hidden = false; + return updated; + }); + hook.list.render(results); + } + } + } + + window.gl = window.gl || {}; + gl.FilteredSearchDropdown = FilteredSearchDropdown; +})(); diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown.js.es6 b/app/assets/javascripts/filtered_search/filtered_search_dropdown.js.es6 deleted file mode 100644 index dd565da507e..00000000000 --- a/app/assets/javascripts/filtered_search/filtered_search_dropdown.js.es6 +++ /dev/null @@ -1,123 +0,0 @@ -(() => { - const DATA_DROPDOWN_TRIGGER = 'data-dropdown-trigger'; - - class FilteredSearchDropdown { - constructor(droplab, dropdown, input, filter) { - this.droplab = droplab; - this.hookId = input && input.getAttribute('data-id'); - this.input = input; - this.filter = filter; - this.dropdown = dropdown; - this.loadingTemplate = `
    - -
    `; - this.bindEvents(); - } - - bindEvents() { - this.itemClickedWrapper = this.itemClicked.bind(this); - this.dropdown.addEventListener('click.dl', this.itemClickedWrapper); - } - - unbindEvents() { - this.dropdown.removeEventListener('click.dl', this.itemClickedWrapper); - } - - getCurrentHook() { - return this.droplab.hooks.filter(h => h.id === this.hookId)[0] || null; - } - - itemClicked(e, getValueFunction) { - const { selected } = e.detail; - - if (selected.tagName === 'LI' && selected.innerHTML) { - const dataValueSet = gl.DropdownUtils.setDataValueIfSelected(this.filter, selected); - - if (!dataValueSet) { - const value = getValueFunction(selected); - gl.FilteredSearchDropdownManager.addWordToInput(this.filter, value); - } - - this.dismissDropdown(); - this.dispatchInputEvent(); - } - } - - setAsDropdown() { - this.input.setAttribute(DATA_DROPDOWN_TRIGGER, `#${this.dropdown.id}`); - } - - setOffset(offset = 0) { - if (window.innerWidth > 480) { - this.dropdown.style.left = `${offset}px`; - } else { - this.dropdown.style.left = '0px'; - } - } - - renderContent(forceShowList = false) { - const currentHook = this.getCurrentHook(); - if (forceShowList && currentHook && currentHook.list.hidden) { - currentHook.list.show(); - } - } - - render(forceRenderContent = false, forceShowList = false) { - this.setAsDropdown(); - - const currentHook = this.getCurrentHook(); - const firstTimeInitialized = currentHook === null; - - if (firstTimeInitialized || forceRenderContent) { - this.renderContent(forceShowList); - } else if (currentHook.list.list.id !== this.dropdown.id) { - this.renderContent(forceShowList); - } - } - - dismissDropdown() { - // Focusing on the input will dismiss dropdown - // (default droplab functionality) - this.input.focus(); - } - - dispatchInputEvent() { - // Propogate input change to FilteredSearchDropdownManager - // so that it can determine which dropdowns to open - this.input.dispatchEvent(new CustomEvent('input', { - bubbles: true, - cancelable: true, - })); - } - - dispatchFormSubmitEvent() { - // dispatchEvent() is necessary as form.submit() does not - // trigger event handlers - this.input.form.dispatchEvent(new Event('submit')); - } - - hideDropdown() { - const currentHook = this.getCurrentHook(); - if (currentHook) { - currentHook.list.hide(); - } - } - - resetFilters() { - const hook = this.getCurrentHook(); - - if (hook) { - const data = hook.list.data; - const results = data.map((o) => { - const updated = o; - updated.droplab_hidden = false; - return updated; - }); - hook.list.render(results); - } - } - } - - window.gl = window.gl || {}; - gl.FilteredSearchDropdown = FilteredSearchDropdown; -})(); diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js new file mode 100644 index 00000000000..cecd3518ce3 --- /dev/null +++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js @@ -0,0 +1,210 @@ +/* global DropLab */ + +(() => { + class FilteredSearchDropdownManager { + constructor(baseEndpoint = '', page) { + this.baseEndpoint = baseEndpoint.replace(/\/$/, ''); + this.tokenizer = gl.FilteredSearchTokenizer; + this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeys; + this.filteredSearchInput = document.querySelector('.filtered-search'); + this.page = page; + + this.setupMapping(); + + this.cleanupWrapper = this.cleanup.bind(this); + document.addEventListener('beforeunload', this.cleanupWrapper); + } + + cleanup() { + if (this.droplab) { + this.droplab.destroy(); + this.droplab = null; + } + + this.setupMapping(); + + document.removeEventListener('beforeunload', this.cleanupWrapper); + } + + setupMapping() { + this.mapping = { + author: { + reference: null, + gl: 'DropdownUser', + element: document.querySelector('#js-dropdown-author'), + }, + assignee: { + reference: null, + gl: 'DropdownUser', + element: document.querySelector('#js-dropdown-assignee'), + }, + milestone: { + reference: null, + gl: 'DropdownNonUser', + extraArguments: [`${this.baseEndpoint}/milestones.json`, '%'], + element: document.querySelector('#js-dropdown-milestone'), + }, + label: { + reference: null, + gl: 'DropdownNonUser', + extraArguments: [`${this.baseEndpoint}/labels.json`, '~'], + element: document.querySelector('#js-dropdown-label'), + }, + hint: { + reference: null, + gl: 'DropdownHint', + element: document.querySelector('#js-dropdown-hint'), + }, + }; + } + + static addWordToInput(tokenName, tokenValue = '') { + const input = document.querySelector('.filtered-search'); + const inputValue = input.value; + const word = `${tokenName}:${tokenValue}`; + + // Get the string to replace + let newCaretPosition = input.selectionStart; + const { left, right } = gl.DropdownUtils.getInputSelectionPosition(input); + + input.value = `${inputValue.substr(0, left)}${word}${inputValue.substr(right)}`; + + // If we have added a tokenValue at the end of the input, + // add a space and set selection to the end + if (right >= inputValue.length && tokenValue !== '') { + input.value += ' '; + newCaretPosition = input.value.length; + } + + gl.FilteredSearchDropdownManager.updateInputCaretPosition(newCaretPosition, input); + } + + static updateInputCaretPosition(selectionStart, input) { + // Reset the position + // Sometimes can end up at end of input + input.setSelectionRange(selectionStart, selectionStart); + + const { right } = gl.DropdownUtils.getInputSelectionPosition(input); + + input.setSelectionRange(right, right); + } + + updateCurrentDropdownOffset() { + this.updateDropdownOffset(this.currentDropdown); + } + + updateDropdownOffset(key) { + if (!this.font) { + this.font = window.getComputedStyle(this.filteredSearchInput).font; + } + + const input = this.filteredSearchInput; + const inputText = input.value.slice(0, input.selectionStart); + const filterIconPadding = 27; + let offset = gl.text.getTextWidth(inputText, this.font) + filterIconPadding; + + const currentDropdownWidth = this.mapping[key].element.clientWidth === 0 ? 200 : + this.mapping[key].element.clientWidth; + const offsetMaxWidth = this.filteredSearchInput.clientWidth - currentDropdownWidth; + + if (offsetMaxWidth < offset) { + offset = offsetMaxWidth; + } + + this.mapping[key].reference.setOffset(offset); + } + + load(key, firstLoad = false) { + const mappingKey = this.mapping[key]; + const glClass = mappingKey.gl; + const element = mappingKey.element; + let forceShowList = false; + + if (!mappingKey.reference) { + const dl = this.droplab; + const defaultArguments = [null, dl, element, this.filteredSearchInput, key]; + const glArguments = defaultArguments.concat(mappingKey.extraArguments || []); + + // Passing glArguments to `new gl[glClass]()` + mappingKey.reference = new (Function.prototype.bind.apply(gl[glClass], glArguments))(); + } + + if (firstLoad) { + mappingKey.reference.init(); + } + + if (this.currentDropdown === 'hint') { + // Force the dropdown to show if it was clicked from the hint dropdown + forceShowList = true; + } + + this.updateDropdownOffset(key); + mappingKey.reference.render(firstLoad, forceShowList); + + this.currentDropdown = key; + } + + loadDropdown(dropdownName = '') { + let firstLoad = false; + + if (!this.droplab) { + firstLoad = true; + this.droplab = new DropLab(); + } + + const match = this.filteredSearchTokenKeys.searchByKey(dropdownName.toLowerCase()); + const shouldOpenFilterDropdown = match && this.currentDropdown !== match.key + && this.mapping[match.key]; + const shouldOpenHintDropdown = !match && this.currentDropdown !== 'hint'; + + if (shouldOpenFilterDropdown || shouldOpenHintDropdown) { + const key = match && match.key ? match.key : 'hint'; + this.load(key, firstLoad); + } + } + + setDropdown() { + const { lastToken, searchToken } = this.tokenizer + .processTokens(gl.DropdownUtils.getSearchInput(this.filteredSearchInput)); + + if (this.currentDropdown) { + this.updateCurrentDropdownOffset(); + } + + if (lastToken === searchToken && lastToken !== null) { + // Token is not fully initialized yet because it has no value + // Eg. token = 'label:' + + const split = lastToken.split(':'); + const dropdownName = split[0].split(' ').last(); + this.loadDropdown(split.length > 1 ? dropdownName : ''); + } else if (lastToken) { + // Token has been initialized into an object because it has a value + this.loadDropdown(lastToken.key); + } else { + this.loadDropdown('hint'); + } + } + + resetDropdowns() { + // Force current dropdown to hide + this.mapping[this.currentDropdown].reference.hideDropdown(); + + // Re-Load dropdown + this.setDropdown(); + + // Reset filters for current dropdown + this.mapping[this.currentDropdown].reference.resetFilters(); + + // Reposition dropdown so that it is aligned with cursor + this.updateDropdownOffset(this.currentDropdown); + } + + destroyDroplab() { + this.droplab.destroy(); + } + } + + window.gl = window.gl || {}; + gl.FilteredSearchDropdownManager = FilteredSearchDropdownManager; +})(); diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js.es6 b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js.es6 deleted file mode 100644 index cecd3518ce3..00000000000 --- a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js.es6 +++ /dev/null @@ -1,210 +0,0 @@ -/* global DropLab */ - -(() => { - class FilteredSearchDropdownManager { - constructor(baseEndpoint = '', page) { - this.baseEndpoint = baseEndpoint.replace(/\/$/, ''); - this.tokenizer = gl.FilteredSearchTokenizer; - this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeys; - this.filteredSearchInput = document.querySelector('.filtered-search'); - this.page = page; - - this.setupMapping(); - - this.cleanupWrapper = this.cleanup.bind(this); - document.addEventListener('beforeunload', this.cleanupWrapper); - } - - cleanup() { - if (this.droplab) { - this.droplab.destroy(); - this.droplab = null; - } - - this.setupMapping(); - - document.removeEventListener('beforeunload', this.cleanupWrapper); - } - - setupMapping() { - this.mapping = { - author: { - reference: null, - gl: 'DropdownUser', - element: document.querySelector('#js-dropdown-author'), - }, - assignee: { - reference: null, - gl: 'DropdownUser', - element: document.querySelector('#js-dropdown-assignee'), - }, - milestone: { - reference: null, - gl: 'DropdownNonUser', - extraArguments: [`${this.baseEndpoint}/milestones.json`, '%'], - element: document.querySelector('#js-dropdown-milestone'), - }, - label: { - reference: null, - gl: 'DropdownNonUser', - extraArguments: [`${this.baseEndpoint}/labels.json`, '~'], - element: document.querySelector('#js-dropdown-label'), - }, - hint: { - reference: null, - gl: 'DropdownHint', - element: document.querySelector('#js-dropdown-hint'), - }, - }; - } - - static addWordToInput(tokenName, tokenValue = '') { - const input = document.querySelector('.filtered-search'); - const inputValue = input.value; - const word = `${tokenName}:${tokenValue}`; - - // Get the string to replace - let newCaretPosition = input.selectionStart; - const { left, right } = gl.DropdownUtils.getInputSelectionPosition(input); - - input.value = `${inputValue.substr(0, left)}${word}${inputValue.substr(right)}`; - - // If we have added a tokenValue at the end of the input, - // add a space and set selection to the end - if (right >= inputValue.length && tokenValue !== '') { - input.value += ' '; - newCaretPosition = input.value.length; - } - - gl.FilteredSearchDropdownManager.updateInputCaretPosition(newCaretPosition, input); - } - - static updateInputCaretPosition(selectionStart, input) { - // Reset the position - // Sometimes can end up at end of input - input.setSelectionRange(selectionStart, selectionStart); - - const { right } = gl.DropdownUtils.getInputSelectionPosition(input); - - input.setSelectionRange(right, right); - } - - updateCurrentDropdownOffset() { - this.updateDropdownOffset(this.currentDropdown); - } - - updateDropdownOffset(key) { - if (!this.font) { - this.font = window.getComputedStyle(this.filteredSearchInput).font; - } - - const input = this.filteredSearchInput; - const inputText = input.value.slice(0, input.selectionStart); - const filterIconPadding = 27; - let offset = gl.text.getTextWidth(inputText, this.font) + filterIconPadding; - - const currentDropdownWidth = this.mapping[key].element.clientWidth === 0 ? 200 : - this.mapping[key].element.clientWidth; - const offsetMaxWidth = this.filteredSearchInput.clientWidth - currentDropdownWidth; - - if (offsetMaxWidth < offset) { - offset = offsetMaxWidth; - } - - this.mapping[key].reference.setOffset(offset); - } - - load(key, firstLoad = false) { - const mappingKey = this.mapping[key]; - const glClass = mappingKey.gl; - const element = mappingKey.element; - let forceShowList = false; - - if (!mappingKey.reference) { - const dl = this.droplab; - const defaultArguments = [null, dl, element, this.filteredSearchInput, key]; - const glArguments = defaultArguments.concat(mappingKey.extraArguments || []); - - // Passing glArguments to `new gl[glClass]()` - mappingKey.reference = new (Function.prototype.bind.apply(gl[glClass], glArguments))(); - } - - if (firstLoad) { - mappingKey.reference.init(); - } - - if (this.currentDropdown === 'hint') { - // Force the dropdown to show if it was clicked from the hint dropdown - forceShowList = true; - } - - this.updateDropdownOffset(key); - mappingKey.reference.render(firstLoad, forceShowList); - - this.currentDropdown = key; - } - - loadDropdown(dropdownName = '') { - let firstLoad = false; - - if (!this.droplab) { - firstLoad = true; - this.droplab = new DropLab(); - } - - const match = this.filteredSearchTokenKeys.searchByKey(dropdownName.toLowerCase()); - const shouldOpenFilterDropdown = match && this.currentDropdown !== match.key - && this.mapping[match.key]; - const shouldOpenHintDropdown = !match && this.currentDropdown !== 'hint'; - - if (shouldOpenFilterDropdown || shouldOpenHintDropdown) { - const key = match && match.key ? match.key : 'hint'; - this.load(key, firstLoad); - } - } - - setDropdown() { - const { lastToken, searchToken } = this.tokenizer - .processTokens(gl.DropdownUtils.getSearchInput(this.filteredSearchInput)); - - if (this.currentDropdown) { - this.updateCurrentDropdownOffset(); - } - - if (lastToken === searchToken && lastToken !== null) { - // Token is not fully initialized yet because it has no value - // Eg. token = 'label:' - - const split = lastToken.split(':'); - const dropdownName = split[0].split(' ').last(); - this.loadDropdown(split.length > 1 ? dropdownName : ''); - } else if (lastToken) { - // Token has been initialized into an object because it has a value - this.loadDropdown(lastToken.key); - } else { - this.loadDropdown('hint'); - } - } - - resetDropdowns() { - // Force current dropdown to hide - this.mapping[this.currentDropdown].reference.hideDropdown(); - - // Re-Load dropdown - this.setDropdown(); - - // Reset filters for current dropdown - this.mapping[this.currentDropdown].reference.resetFilters(); - - // Reposition dropdown so that it is aligned with cursor - this.updateDropdownOffset(this.currentDropdown); - } - - destroyDroplab() { - this.droplab.destroy(); - } - } - - window.gl = window.gl || {}; - gl.FilteredSearchDropdownManager = FilteredSearchDropdownManager; -})(); diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js new file mode 100644 index 00000000000..bbafead0305 --- /dev/null +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -0,0 +1,231 @@ +(() => { + class FilteredSearchManager { + constructor(page) { + this.filteredSearchInput = document.querySelector('.filtered-search'); + this.clearSearchButton = document.querySelector('.clear-search'); + this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeys; + + if (this.filteredSearchInput) { + this.tokenizer = gl.FilteredSearchTokenizer; + this.dropdownManager = new gl.FilteredSearchDropdownManager(this.filteredSearchInput.getAttribute('data-base-endpoint') || '', page); + + this.bindEvents(); + this.loadSearchParamsFromURL(); + this.dropdownManager.setDropdown(); + + this.cleanupWrapper = this.cleanup.bind(this); + document.addEventListener('beforeunload', this.cleanupWrapper); + } + } + + cleanup() { + this.unbindEvents(); + document.removeEventListener('beforeunload', this.cleanupWrapper); + } + + bindEvents() { + this.handleFormSubmit = this.handleFormSubmit.bind(this); + this.setDropdownWrapper = this.dropdownManager.setDropdown.bind(this.dropdownManager); + this.toggleClearSearchButtonWrapper = this.toggleClearSearchButton.bind(this); + this.checkForEnterWrapper = this.checkForEnter.bind(this); + this.clearSearchWrapper = this.clearSearch.bind(this); + this.checkForBackspaceWrapper = this.checkForBackspace.bind(this); + this.tokenChange = this.tokenChange.bind(this); + + this.filteredSearchInput.form.addEventListener('submit', this.handleFormSubmit); + this.filteredSearchInput.addEventListener('input', this.setDropdownWrapper); + this.filteredSearchInput.addEventListener('input', this.toggleClearSearchButtonWrapper); + this.filteredSearchInput.addEventListener('keydown', this.checkForEnterWrapper); + this.filteredSearchInput.addEventListener('keyup', this.checkForBackspaceWrapper); + this.filteredSearchInput.addEventListener('click', this.tokenChange); + this.filteredSearchInput.addEventListener('keyup', this.tokenChange); + this.clearSearchButton.addEventListener('click', this.clearSearchWrapper); + } + + unbindEvents() { + this.filteredSearchInput.form.removeEventListener('submit', this.handleFormSubmit); + this.filteredSearchInput.removeEventListener('input', this.setDropdownWrapper); + this.filteredSearchInput.removeEventListener('input', this.toggleClearSearchButtonWrapper); + this.filteredSearchInput.removeEventListener('keydown', this.checkForEnterWrapper); + this.filteredSearchInput.removeEventListener('keyup', this.checkForBackspaceWrapper); + this.filteredSearchInput.removeEventListener('click', this.tokenChange); + this.filteredSearchInput.removeEventListener('keyup', this.tokenChange); + this.clearSearchButton.removeEventListener('click', this.clearSearchWrapper); + } + + checkForBackspace(e) { + // 8 = Backspace Key + // 46 = Delete Key + if (e.keyCode === 8 || e.keyCode === 46) { + // Reposition dropdown so that it is aligned with cursor + this.dropdownManager.updateCurrentDropdownOffset(); + } + } + + checkForEnter(e) { + if (e.keyCode === 38 || e.keyCode === 40) { + const selectionStart = this.filteredSearchInput.selectionStart; + + e.preventDefault(); + this.filteredSearchInput.setSelectionRange(selectionStart, selectionStart); + } + + if (e.keyCode === 13) { + const dropdown = this.dropdownManager.mapping[this.dropdownManager.currentDropdown]; + const dropdownEl = dropdown.element; + const activeElements = dropdownEl.querySelectorAll('.dropdown-active'); + + e.preventDefault(); + + if (!activeElements.length) { + // Prevent droplab from opening dropdown + this.dropdownManager.destroyDroplab(); + + this.search(); + } + } + } + + toggleClearSearchButton(e) { + if (e.target.value) { + this.clearSearchButton.classList.remove('hidden'); + } else { + this.clearSearchButton.classList.add('hidden'); + } + } + + clearSearch(e) { + e.preventDefault(); + + this.filteredSearchInput.value = ''; + this.clearSearchButton.classList.add('hidden'); + + this.dropdownManager.resetDropdowns(); + } + + handleFormSubmit(e) { + e.preventDefault(); + this.search(); + } + + loadSearchParamsFromURL() { + const params = gl.utils.getUrlParamsArray(); + const usernameParams = this.getUsernameParams(); + const inputValues = []; + + params.forEach((p) => { + const split = p.split('='); + const keyParam = decodeURIComponent(split[0]); + const value = split[1]; + + // Check if it matches edge conditions listed in this.filteredSearchTokenKeys + const condition = this.filteredSearchTokenKeys.searchByConditionUrl(p); + + if (condition) { + inputValues.push(`${condition.tokenKey}:${condition.value}`); + } else { + // Sanitize value since URL converts spaces into + + // Replace before decode so that we know what was originally + versus the encoded + + const sanitizedValue = value ? decodeURIComponent(value.replace(/\+/g, ' ')) : value; + const match = this.filteredSearchTokenKeys.searchByKeyParam(keyParam); + + if (match) { + const indexOf = keyParam.indexOf('_'); + const sanitizedKey = indexOf !== -1 ? keyParam.slice(0, keyParam.indexOf('_')) : keyParam; + const symbol = match.symbol; + let quotationsToUse = ''; + + if (sanitizedValue.indexOf(' ') !== -1) { + // Prefer ", but use ' if required + quotationsToUse = sanitizedValue.indexOf('"') === -1 ? '"' : '\''; + } + + inputValues.push(`${sanitizedKey}:${symbol}${quotationsToUse}${sanitizedValue}${quotationsToUse}`); + } else if (!match && keyParam === 'assignee_id') { + const id = parseInt(value, 10); + if (usernameParams[id]) { + inputValues.push(`assignee:@${usernameParams[id]}`); + } + } else if (!match && keyParam === 'author_id') { + const id = parseInt(value, 10); + if (usernameParams[id]) { + inputValues.push(`author:@${usernameParams[id]}`); + } + } else if (!match && keyParam === 'search') { + inputValues.push(sanitizedValue); + } + } + }); + + // Trim the last space value + this.filteredSearchInput.value = inputValues.join(' '); + + if (inputValues.length > 0) { + this.clearSearchButton.classList.remove('hidden'); + } + } + + search() { + const paths = []; + const { tokens, searchToken } = this.tokenizer.processTokens(this.filteredSearchInput.value); + const currentState = gl.utils.getParameterByName('state') || 'opened'; + paths.push(`state=${currentState}`); + + tokens.forEach((token) => { + const condition = this.filteredSearchTokenKeys + .searchByConditionKeyValue(token.key, token.value.toLowerCase()); + const { param } = this.filteredSearchTokenKeys.searchByKey(token.key) || {}; + const keyParam = param ? `${token.key}_${param}` : token.key; + let tokenPath = ''; + + if (condition) { + tokenPath = condition.url; + } else { + let tokenValue = token.value; + + if ((tokenValue[0] === '\'' && tokenValue[tokenValue.length - 1] === '\'') || + (tokenValue[0] === '"' && tokenValue[tokenValue.length - 1] === '"')) { + tokenValue = tokenValue.slice(1, tokenValue.length - 1); + } + + tokenPath = `${keyParam}=${encodeURIComponent(tokenValue)}`; + } + + paths.push(tokenPath); + }); + + if (searchToken) { + const sanitized = searchToken.split(' ').map(t => encodeURIComponent(t)).join('+'); + paths.push(`search=${sanitized}`); + } + + const parameterizedUrl = `?scope=all&utf8=✓&${paths.join('&')}`; + + gl.utils.visitUrl(parameterizedUrl); + } + + getUsernameParams() { + const usernamesById = {}; + try { + const attribute = this.filteredSearchInput.getAttribute('data-username-params'); + JSON.parse(attribute).forEach((user) => { + usernamesById[user.id] = user.username; + }); + } catch (e) { + // do nothing + } + return usernamesById; + } + + tokenChange() { + const dropdown = this.dropdownManager.mapping[this.dropdownManager.currentDropdown]; + const currentDropdownRef = dropdown.reference; + + this.setDropdownWrapper(); + currentDropdownRef.dispatchInputEvent(); + } + } + + window.gl = window.gl || {}; + gl.FilteredSearchManager = FilteredSearchManager; +})(); diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js.es6 b/app/assets/javascripts/filtered_search/filtered_search_manager.js.es6 deleted file mode 100644 index bbafead0305..00000000000 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js.es6 +++ /dev/null @@ -1,231 +0,0 @@ -(() => { - class FilteredSearchManager { - constructor(page) { - this.filteredSearchInput = document.querySelector('.filtered-search'); - this.clearSearchButton = document.querySelector('.clear-search'); - this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeys; - - if (this.filteredSearchInput) { - this.tokenizer = gl.FilteredSearchTokenizer; - this.dropdownManager = new gl.FilteredSearchDropdownManager(this.filteredSearchInput.getAttribute('data-base-endpoint') || '', page); - - this.bindEvents(); - this.loadSearchParamsFromURL(); - this.dropdownManager.setDropdown(); - - this.cleanupWrapper = this.cleanup.bind(this); - document.addEventListener('beforeunload', this.cleanupWrapper); - } - } - - cleanup() { - this.unbindEvents(); - document.removeEventListener('beforeunload', this.cleanupWrapper); - } - - bindEvents() { - this.handleFormSubmit = this.handleFormSubmit.bind(this); - this.setDropdownWrapper = this.dropdownManager.setDropdown.bind(this.dropdownManager); - this.toggleClearSearchButtonWrapper = this.toggleClearSearchButton.bind(this); - this.checkForEnterWrapper = this.checkForEnter.bind(this); - this.clearSearchWrapper = this.clearSearch.bind(this); - this.checkForBackspaceWrapper = this.checkForBackspace.bind(this); - this.tokenChange = this.tokenChange.bind(this); - - this.filteredSearchInput.form.addEventListener('submit', this.handleFormSubmit); - this.filteredSearchInput.addEventListener('input', this.setDropdownWrapper); - this.filteredSearchInput.addEventListener('input', this.toggleClearSearchButtonWrapper); - this.filteredSearchInput.addEventListener('keydown', this.checkForEnterWrapper); - this.filteredSearchInput.addEventListener('keyup', this.checkForBackspaceWrapper); - this.filteredSearchInput.addEventListener('click', this.tokenChange); - this.filteredSearchInput.addEventListener('keyup', this.tokenChange); - this.clearSearchButton.addEventListener('click', this.clearSearchWrapper); - } - - unbindEvents() { - this.filteredSearchInput.form.removeEventListener('submit', this.handleFormSubmit); - this.filteredSearchInput.removeEventListener('input', this.setDropdownWrapper); - this.filteredSearchInput.removeEventListener('input', this.toggleClearSearchButtonWrapper); - this.filteredSearchInput.removeEventListener('keydown', this.checkForEnterWrapper); - this.filteredSearchInput.removeEventListener('keyup', this.checkForBackspaceWrapper); - this.filteredSearchInput.removeEventListener('click', this.tokenChange); - this.filteredSearchInput.removeEventListener('keyup', this.tokenChange); - this.clearSearchButton.removeEventListener('click', this.clearSearchWrapper); - } - - checkForBackspace(e) { - // 8 = Backspace Key - // 46 = Delete Key - if (e.keyCode === 8 || e.keyCode === 46) { - // Reposition dropdown so that it is aligned with cursor - this.dropdownManager.updateCurrentDropdownOffset(); - } - } - - checkForEnter(e) { - if (e.keyCode === 38 || e.keyCode === 40) { - const selectionStart = this.filteredSearchInput.selectionStart; - - e.preventDefault(); - this.filteredSearchInput.setSelectionRange(selectionStart, selectionStart); - } - - if (e.keyCode === 13) { - const dropdown = this.dropdownManager.mapping[this.dropdownManager.currentDropdown]; - const dropdownEl = dropdown.element; - const activeElements = dropdownEl.querySelectorAll('.dropdown-active'); - - e.preventDefault(); - - if (!activeElements.length) { - // Prevent droplab from opening dropdown - this.dropdownManager.destroyDroplab(); - - this.search(); - } - } - } - - toggleClearSearchButton(e) { - if (e.target.value) { - this.clearSearchButton.classList.remove('hidden'); - } else { - this.clearSearchButton.classList.add('hidden'); - } - } - - clearSearch(e) { - e.preventDefault(); - - this.filteredSearchInput.value = ''; - this.clearSearchButton.classList.add('hidden'); - - this.dropdownManager.resetDropdowns(); - } - - handleFormSubmit(e) { - e.preventDefault(); - this.search(); - } - - loadSearchParamsFromURL() { - const params = gl.utils.getUrlParamsArray(); - const usernameParams = this.getUsernameParams(); - const inputValues = []; - - params.forEach((p) => { - const split = p.split('='); - const keyParam = decodeURIComponent(split[0]); - const value = split[1]; - - // Check if it matches edge conditions listed in this.filteredSearchTokenKeys - const condition = this.filteredSearchTokenKeys.searchByConditionUrl(p); - - if (condition) { - inputValues.push(`${condition.tokenKey}:${condition.value}`); - } else { - // Sanitize value since URL converts spaces into + - // Replace before decode so that we know what was originally + versus the encoded + - const sanitizedValue = value ? decodeURIComponent(value.replace(/\+/g, ' ')) : value; - const match = this.filteredSearchTokenKeys.searchByKeyParam(keyParam); - - if (match) { - const indexOf = keyParam.indexOf('_'); - const sanitizedKey = indexOf !== -1 ? keyParam.slice(0, keyParam.indexOf('_')) : keyParam; - const symbol = match.symbol; - let quotationsToUse = ''; - - if (sanitizedValue.indexOf(' ') !== -1) { - // Prefer ", but use ' if required - quotationsToUse = sanitizedValue.indexOf('"') === -1 ? '"' : '\''; - } - - inputValues.push(`${sanitizedKey}:${symbol}${quotationsToUse}${sanitizedValue}${quotationsToUse}`); - } else if (!match && keyParam === 'assignee_id') { - const id = parseInt(value, 10); - if (usernameParams[id]) { - inputValues.push(`assignee:@${usernameParams[id]}`); - } - } else if (!match && keyParam === 'author_id') { - const id = parseInt(value, 10); - if (usernameParams[id]) { - inputValues.push(`author:@${usernameParams[id]}`); - } - } else if (!match && keyParam === 'search') { - inputValues.push(sanitizedValue); - } - } - }); - - // Trim the last space value - this.filteredSearchInput.value = inputValues.join(' '); - - if (inputValues.length > 0) { - this.clearSearchButton.classList.remove('hidden'); - } - } - - search() { - const paths = []; - const { tokens, searchToken } = this.tokenizer.processTokens(this.filteredSearchInput.value); - const currentState = gl.utils.getParameterByName('state') || 'opened'; - paths.push(`state=${currentState}`); - - tokens.forEach((token) => { - const condition = this.filteredSearchTokenKeys - .searchByConditionKeyValue(token.key, token.value.toLowerCase()); - const { param } = this.filteredSearchTokenKeys.searchByKey(token.key) || {}; - const keyParam = param ? `${token.key}_${param}` : token.key; - let tokenPath = ''; - - if (condition) { - tokenPath = condition.url; - } else { - let tokenValue = token.value; - - if ((tokenValue[0] === '\'' && tokenValue[tokenValue.length - 1] === '\'') || - (tokenValue[0] === '"' && tokenValue[tokenValue.length - 1] === '"')) { - tokenValue = tokenValue.slice(1, tokenValue.length - 1); - } - - tokenPath = `${keyParam}=${encodeURIComponent(tokenValue)}`; - } - - paths.push(tokenPath); - }); - - if (searchToken) { - const sanitized = searchToken.split(' ').map(t => encodeURIComponent(t)).join('+'); - paths.push(`search=${sanitized}`); - } - - const parameterizedUrl = `?scope=all&utf8=✓&${paths.join('&')}`; - - gl.utils.visitUrl(parameterizedUrl); - } - - getUsernameParams() { - const usernamesById = {}; - try { - const attribute = this.filteredSearchInput.getAttribute('data-username-params'); - JSON.parse(attribute).forEach((user) => { - usernamesById[user.id] = user.username; - }); - } catch (e) { - // do nothing - } - return usernamesById; - } - - tokenChange() { - const dropdown = this.dropdownManager.mapping[this.dropdownManager.currentDropdown]; - const currentDropdownRef = dropdown.reference; - - this.setDropdownWrapper(); - currentDropdownRef.dispatchInputEvent(); - } - } - - window.gl = window.gl || {}; - gl.FilteredSearchManager = FilteredSearchManager; -})(); diff --git a/app/assets/javascripts/filtered_search/filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/filtered_search_token_keys.js new file mode 100644 index 00000000000..e6b53cd4b55 --- /dev/null +++ b/app/assets/javascripts/filtered_search/filtered_search_token_keys.js @@ -0,0 +1,96 @@ +(() => { + const tokenKeys = [{ + key: 'author', + type: 'string', + param: 'username', + symbol: '@', + }, { + key: 'assignee', + type: 'string', + param: 'username', + symbol: '@', + }, { + key: 'milestone', + type: 'string', + param: 'title', + symbol: '%', + }, { + key: 'label', + type: 'array', + param: 'name[]', + symbol: '~', + }]; + + const alternativeTokenKeys = [{ + key: 'label', + type: 'string', + param: 'name', + symbol: '~', + }]; + + const tokenKeysWithAlternative = tokenKeys.concat(alternativeTokenKeys); + + const conditions = [{ + url: 'assignee_id=0', + tokenKey: 'assignee', + value: 'none', + }, { + url: 'milestone_title=No+Milestone', + tokenKey: 'milestone', + value: 'none', + }, { + url: 'milestone_title=%23upcoming', + tokenKey: 'milestone', + value: 'upcoming', + }, { + url: 'label_name[]=No+Label', + tokenKey: 'label', + value: 'none', + }]; + + class FilteredSearchTokenKeys { + static get() { + return tokenKeys; + } + + static getAlternatives() { + return alternativeTokenKeys; + } + + static getConditions() { + return conditions; + } + + static searchByKey(key) { + return tokenKeys.find(tokenKey => tokenKey.key === key) || null; + } + + static searchBySymbol(symbol) { + return tokenKeys.find(tokenKey => tokenKey.symbol === symbol) || null; + } + + static searchByKeyParam(keyParam) { + return tokenKeysWithAlternative.find((tokenKey) => { + let tokenKeyParam = tokenKey.key; + + if (tokenKey.param) { + tokenKeyParam += `_${tokenKey.param}`; + } + + return keyParam === tokenKeyParam; + }) || null; + } + + static searchByConditionUrl(url) { + return conditions.find(condition => condition.url === url) || null; + } + + static searchByConditionKeyValue(key, value) { + return conditions + .find(condition => condition.tokenKey === key && condition.value === value) || null; + } + } + + window.gl = window.gl || {}; + gl.FilteredSearchTokenKeys = FilteredSearchTokenKeys; +})(); diff --git a/app/assets/javascripts/filtered_search/filtered_search_token_keys.js.es6 b/app/assets/javascripts/filtered_search/filtered_search_token_keys.js.es6 deleted file mode 100644 index e6b53cd4b55..00000000000 --- a/app/assets/javascripts/filtered_search/filtered_search_token_keys.js.es6 +++ /dev/null @@ -1,96 +0,0 @@ -(() => { - const tokenKeys = [{ - key: 'author', - type: 'string', - param: 'username', - symbol: '@', - }, { - key: 'assignee', - type: 'string', - param: 'username', - symbol: '@', - }, { - key: 'milestone', - type: 'string', - param: 'title', - symbol: '%', - }, { - key: 'label', - type: 'array', - param: 'name[]', - symbol: '~', - }]; - - const alternativeTokenKeys = [{ - key: 'label', - type: 'string', - param: 'name', - symbol: '~', - }]; - - const tokenKeysWithAlternative = tokenKeys.concat(alternativeTokenKeys); - - const conditions = [{ - url: 'assignee_id=0', - tokenKey: 'assignee', - value: 'none', - }, { - url: 'milestone_title=No+Milestone', - tokenKey: 'milestone', - value: 'none', - }, { - url: 'milestone_title=%23upcoming', - tokenKey: 'milestone', - value: 'upcoming', - }, { - url: 'label_name[]=No+Label', - tokenKey: 'label', - value: 'none', - }]; - - class FilteredSearchTokenKeys { - static get() { - return tokenKeys; - } - - static getAlternatives() { - return alternativeTokenKeys; - } - - static getConditions() { - return conditions; - } - - static searchByKey(key) { - return tokenKeys.find(tokenKey => tokenKey.key === key) || null; - } - - static searchBySymbol(symbol) { - return tokenKeys.find(tokenKey => tokenKey.symbol === symbol) || null; - } - - static searchByKeyParam(keyParam) { - return tokenKeysWithAlternative.find((tokenKey) => { - let tokenKeyParam = tokenKey.key; - - if (tokenKey.param) { - tokenKeyParam += `_${tokenKey.param}`; - } - - return keyParam === tokenKeyParam; - }) || null; - } - - static searchByConditionUrl(url) { - return conditions.find(condition => condition.url === url) || null; - } - - static searchByConditionKeyValue(key, value) { - return conditions - .find(condition => condition.tokenKey === key && condition.value === value) || null; - } - } - - window.gl = window.gl || {}; - gl.FilteredSearchTokenKeys = FilteredSearchTokenKeys; -})(); diff --git a/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js b/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js new file mode 100644 index 00000000000..9bf1b1ced88 --- /dev/null +++ b/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js @@ -0,0 +1,48 @@ +require('./filtered_search_token_keys'); + +(() => { + class FilteredSearchTokenizer { + static processTokens(input) { + const allowedKeys = gl.FilteredSearchTokenKeys.get().map(i => i.key); + // Regex extracts `(token):(symbol)(value)` + // Values that start with a double quote must end in a double quote (same for single) + const tokenRegex = new RegExp(`(${allowedKeys.join('|')}):([~%@]?)(?:('[^']*'{0,1})|("[^"]*"{0,1})|(\\S+))`, 'g'); + const tokens = []; + let lastToken = null; + const searchToken = input.replace(tokenRegex, (match, key, symbol, v1, v2, v3) => { + let tokenValue = v1 || v2 || v3; + let tokenSymbol = symbol; + + if (tokenValue === '~' || tokenValue === '%' || tokenValue === '@') { + tokenSymbol = tokenValue; + tokenValue = ''; + } + + tokens.push({ + key, + value: tokenValue || '', + symbol: tokenSymbol || '', + }); + return ''; + }).replace(/\s{2,}/g, ' ').trim() || ''; + + if (tokens.length > 0) { + const last = tokens[tokens.length - 1]; + const lastString = `${last.key}:${last.symbol}${last.value}`; + lastToken = input.lastIndexOf(lastString) === + input.length - lastString.length ? last : searchToken; + } else { + lastToken = searchToken; + } + + return { + tokens, + lastToken, + searchToken, + }; + } + } + + window.gl = window.gl || {}; + gl.FilteredSearchTokenizer = FilteredSearchTokenizer; +})(); diff --git a/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js.es6 b/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js.es6 deleted file mode 100644 index 9bf1b1ced88..00000000000 --- a/app/assets/javascripts/filtered_search/filtered_search_tokenizer.js.es6 +++ /dev/null @@ -1,48 +0,0 @@ -require('./filtered_search_token_keys'); - -(() => { - class FilteredSearchTokenizer { - static processTokens(input) { - const allowedKeys = gl.FilteredSearchTokenKeys.get().map(i => i.key); - // Regex extracts `(token):(symbol)(value)` - // Values that start with a double quote must end in a double quote (same for single) - const tokenRegex = new RegExp(`(${allowedKeys.join('|')}):([~%@]?)(?:('[^']*'{0,1})|("[^"]*"{0,1})|(\\S+))`, 'g'); - const tokens = []; - let lastToken = null; - const searchToken = input.replace(tokenRegex, (match, key, symbol, v1, v2, v3) => { - let tokenValue = v1 || v2 || v3; - let tokenSymbol = symbol; - - if (tokenValue === '~' || tokenValue === '%' || tokenValue === '@') { - tokenSymbol = tokenValue; - tokenValue = ''; - } - - tokens.push({ - key, - value: tokenValue || '', - symbol: tokenSymbol || '', - }); - return ''; - }).replace(/\s{2,}/g, ' ').trim() || ''; - - if (tokens.length > 0) { - const last = tokens[tokens.length - 1]; - const lastString = `${last.key}:${last.symbol}${last.value}`; - lastToken = input.lastIndexOf(lastString) === - input.length - lastString.length ? last : searchToken; - } else { - lastToken = searchToken; - } - - return { - tokens, - lastToken, - searchToken, - }; - } - } - - window.gl = window.gl || {}; - gl.FilteredSearchTokenizer = FilteredSearchTokenizer; -})(); diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js new file mode 100644 index 00000000000..60d6658dc16 --- /dev/null +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -0,0 +1,383 @@ +/* eslint-disable func-names, space-before-function-paren, no-template-curly-in-string, comma-dangle, object-shorthand, quotes, dot-notation, no-else-return, one-var, no-var, no-underscore-dangle, one-var-declaration-per-line, no-param-reassign, no-useless-escape, prefer-template, consistent-return, wrap-iife, prefer-arrow-callback, camelcase, no-unused-vars, no-useless-return, vars-on-top, max-len */ + +// Creates the variables for setting up GFM auto-completion +(function() { + if (window.gl == null) { + window.gl = {}; + } + + 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: { + template: '
  • ${name} ${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); + }, + 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, "\\$&"); + + _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'); + + match = regexp.exec(subtext); + + 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.path != null ? this.Emoji.template : 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}
    `; + + 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: '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: '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 + }; + }); + } + } + }); + // 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 %>'; + } + } + 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 { + 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; + } + } + } + }); + 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 { + $.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); + } + }; +}).call(window); diff --git a/app/assets/javascripts/gfm_auto_complete.js.es6 b/app/assets/javascripts/gfm_auto_complete.js.es6 deleted file mode 100644 index 60d6658dc16..00000000000 --- a/app/assets/javascripts/gfm_auto_complete.js.es6 +++ /dev/null @@ -1,383 +0,0 @@ -/* eslint-disable func-names, space-before-function-paren, no-template-curly-in-string, comma-dangle, object-shorthand, quotes, dot-notation, no-else-return, one-var, no-var, no-underscore-dangle, one-var-declaration-per-line, no-param-reassign, no-useless-escape, prefer-template, consistent-return, wrap-iife, prefer-arrow-callback, camelcase, no-unused-vars, no-useless-return, vars-on-top, max-len */ - -// Creates the variables for setting up GFM auto-completion -(function() { - if (window.gl == null) { - window.gl = {}; - } - - 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: { - template: '
  • ${name} ${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); - }, - 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, "\\$&"); - - _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'); - - match = regexp.exec(subtext); - - 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.path != null ? this.Emoji.template : 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}
    `; - - 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: '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: '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 - }; - }); - } - } - }); - // 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 %>'; - } - } - 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 { - 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; - } - } - } - }); - 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 { - $.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); - } - }; -}).call(window); diff --git a/app/assets/javascripts/gl_field_error.js b/app/assets/javascripts/gl_field_error.js new file mode 100644 index 00000000000..f7cbecc0385 --- /dev/null +++ b/app/assets/javascripts/gl_field_error.js @@ -0,0 +1,164 @@ +/* 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(); + } + + 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(); + } + + 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; + + return safelyScoped ? unignoredSiblings : this.fieldErrorElement; + } + + renderValidity() { + this.renderClear(); + + 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)); + } + + /* Get or set current input value */ + accessCurrentValue(newVal) { + return newVal ? this.inputElement.val(newVal) : this.inputElement.val(); + } + + getInputValidity() { + return this.inputDomElement.validity.valid; + } + + updateValidity() { + const inputVal = this.accessCurrentValue(); + this.state.empty = !inputVal.length; + this.state.valid = this.getInputValidity(); + this.renderValidity(); + } + + renderValid() { + return this.renderClear(); + } + + renderEmpty() { + return this.renderInvalid(); + } + + 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(); + } + } + + global.GlFieldError = GlFieldError; +})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/gl_field_error.js.es6 b/app/assets/javascripts/gl_field_error.js.es6 deleted file mode 100644 index f7cbecc0385..00000000000 --- a/app/assets/javascripts/gl_field_error.js.es6 +++ /dev/null @@ -1,164 +0,0 @@ -/* 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(); - } - - 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(); - } - - 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; - - return safelyScoped ? unignoredSiblings : this.fieldErrorElement; - } - - renderValidity() { - this.renderClear(); - - 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)); - } - - /* Get or set current input value */ - accessCurrentValue(newVal) { - return newVal ? this.inputElement.val(newVal) : this.inputElement.val(); - } - - getInputValidity() { - return this.inputDomElement.validity.valid; - } - - updateValidity() { - const inputVal = this.accessCurrentValue(); - this.state.empty = !inputVal.length; - this.state.valid = this.getInputValidity(); - this.renderValidity(); - } - - renderValid() { - return this.renderClear(); - } - - renderEmpty() { - return this.renderInvalid(); - } - - 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(); - } - } - - global.GlFieldError = GlFieldError; -})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/gl_field_errors.js b/app/assets/javascripts/gl_field_errors.js new file mode 100644 index 00000000000..e9add115429 --- /dev/null +++ b/app/assets/javascripts/gl_field_errors.js @@ -0,0 +1,48 @@ +/* eslint-disable comma-dangle, class-methods-use-this, max-len, space-before-function-paren, arrow-parens, no-param-reassign */ + +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(); + } + + 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.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 */ + + 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(); + } + } + + global.GlFieldErrors = GlFieldErrors; +})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/gl_field_errors.js.es6 b/app/assets/javascripts/gl_field_errors.js.es6 deleted file mode 100644 index e9add115429..00000000000 --- a/app/assets/javascripts/gl_field_errors.js.es6 +++ /dev/null @@ -1,48 +0,0 @@ -/* eslint-disable comma-dangle, class-methods-use-this, max-len, space-before-function-paren, arrow-parens, no-param-reassign */ - -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(); - } - - 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.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 */ - - 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(); - } - } - - global.GlFieldErrors = GlFieldErrors; -})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/gl_form.js b/app/assets/javascripts/gl_form.js new file mode 100644 index 00000000000..0b446ff364a --- /dev/null +++ b/app/assets/javascripts/gl_form.js @@ -0,0 +1,92 @@ +/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, no-new, max-len */ +/* global GitLab */ +/* global DropzoneInput */ +/* global autosize */ + +(() => { + const global = 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); + } + + 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.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)); + + setTimeout(() => { + autosize(this.textarea); + this.textarea.css('resize', 'vertical'); + }, 0); + }; + + GLForm.prototype.setHeightData = function () { + this.textarea.data('height', this.textarea.outerHeight()); + }; + + GLForm.prototype.destroyAutosize = function () { + const outerHeight = this.textarea.outerHeight(); + + if (this.textarea.data('height') === outerHeight) return; + + autosize.destroy(this.textarea); + + 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.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; +})(); diff --git a/app/assets/javascripts/gl_form.js.es6 b/app/assets/javascripts/gl_form.js.es6 deleted file mode 100644 index 0b446ff364a..00000000000 --- a/app/assets/javascripts/gl_form.js.es6 +++ /dev/null @@ -1,92 +0,0 @@ -/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, no-new, max-len */ -/* global GitLab */ -/* global DropzoneInput */ -/* global autosize */ - -(() => { - const global = 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); - } - - 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.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)); - - setTimeout(() => { - autosize(this.textarea); - this.textarea.css('resize', 'vertical'); - }, 0); - }; - - GLForm.prototype.setHeightData = function () { - this.textarea.data('height', this.textarea.outerHeight()); - }; - - GLForm.prototype.destroyAutosize = function () { - const outerHeight = this.textarea.outerHeight(); - - if (this.textarea.data('height') === outerHeight) return; - - autosize.destroy(this.textarea); - - 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.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; -})(); diff --git a/app/assets/javascripts/group_label_subscription.js b/app/assets/javascripts/group_label_subscription.js new file mode 100644 index 00000000000..15e695e81cf --- /dev/null +++ b/app/assets/javascripts/group_label_subscription.js @@ -0,0 +1,53 @@ +/* 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'); + } + } + + global.GroupLabelSubscription = GroupLabelSubscription; +})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/group_label_subscription.js.es6 b/app/assets/javascripts/group_label_subscription.js.es6 deleted file mode 100644 index 15e695e81cf..00000000000 --- a/app/assets/javascripts/group_label_subscription.js.es6 +++ /dev/null @@ -1,53 +0,0 @@ -/* 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'); - } - } - - global.GroupLabelSubscription = GroupLabelSubscription; -})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/issuable.js b/app/assets/javascripts/issuable.js new file mode 100644 index 00000000000..3bfce32768a --- /dev/null +++ b/app/assets/javascripts/issuable.js @@ -0,0 +1,188 @@ +/* eslint-disable no-param-reassign, func-names, no-var, camelcase, no-unused-vars, object-shorthand, space-before-function-paren, no-return-assign, comma-dangle, consistent-return, one-var, one-var-declaration-per-line, quotes, prefer-template, prefer-arrow-callback, wrap-iife, max-len */ +/* global Issuable */ + +((global) => { + var issuable_created; + + issuable_created = false; + + global.Issuable = { + init: function() { + Issuable.initTemplates(); + Issuable.initSearch(); + Issuable.initChecks(); + Issuable.initResetFilters(); + Issuable.resetIncomingEmailToken(); + return Issuable.initLabelFilterRemove(); + }, + initTemplates: function() { + return Issuable.labelRow = _.template('<% _.each(labels, function(label){ %> <%- label.title %> <% }); %>'); + }, + initSearch: function() { + const $searchInput = $('#issuable_search'); + + Issuable.initSearchState($searchInput); + + // `immediate` param set to false debounces on the `trailing` edge, lets user finish typing + const debouncedExecSearch = _.debounce(Issuable.executeSearch, 1000, false); + + $searchInput.off('keyup').on('keyup', debouncedExecSearch); + + // ensures existing filters are preserved when manually submitted + $('#issuable_search_form').on('submit', (e) => { + e.preventDefault(); + debouncedExecSearch(e); + }); + }, + initSearchState: function($searchInput) { + const currentSearchVal = $searchInput.val(); + + Issuable.searchState = { + elem: $searchInput, + current: currentSearchVal + }; + + Issuable.maybeFocusOnSearch(); + }, + accessSearchPristine: function(set) { + // store reference to previous value to prevent search on non-mutating keyup + const state = Issuable.searchState; + const currentSearchVal = state.elem.val(); + + if (set) { + state.current = currentSearchVal; + } else { + return state.current === currentSearchVal; + } + }, + maybeFocusOnSearch: function() { + const currentSearchVal = Issuable.searchState.current; + if (currentSearchVal && currentSearchVal !== '') { + const queryLength = currentSearchVal.length; + const $searchInput = Issuable.searchState.elem; + + /* The following ensures that the cursor is initially placed at + * the end of search input when focus is applied. It accounts + * for differences in browser implementations of `setSelectionRange` + * and cursor placement for elements in focus. + */ + $searchInput.focus(); + if ($searchInput.setSelectionRange) { + $searchInput.setSelectionRange(queryLength, queryLength); + } else { + $searchInput.val(currentSearchVal); + } + } + }, + executeSearch: function(e) { + const $search = $('#issuable_search'); + const $searchName = $search.attr('name'); + const $searchValue = $search.val(); + const $filtersForm = $('.js-filter-form'); + const $input = $(`input[name='${$searchName}']`, $filtersForm); + const isPristine = Issuable.accessSearchPristine(); + + if (isPristine) { + return; + } + + if (!$input.length) { + $filtersForm.append(``); + } else { + $input.val($searchValue); + } + + Issuable.filterResults($filtersForm); + }, + initLabelFilterRemove: function() { + return $(document).off('click', '.js-label-filter-remove').on('click', '.js-label-filter-remove', function(e) { + var $button; + $button = $(this); + // Remove the label input box + $('input[name="label_name[]"]').filter(function() { + return this.value === $button.data('label'); + }).remove(); + // Submit the form to get new data + Issuable.filterResults($('.filter-form')); + }); + }, + filterResults: (function(_this) { + return function(form) { + var formAction, formData, issuesUrl; + formData = form.serializeArray(); + formData = formData.filter(function(data) { + return data.value !== ''; + }); + formData = $.param(formData); + formAction = form.attr('action'); + issuesUrl = formAction; + issuesUrl += "" + (formAction.indexOf('?') === -1 ? '?' : '&'); + issuesUrl += formData; + return gl.utils.visitUrl(issuesUrl); + }; + })(this), + initResetFilters: function() { + $('.reset-filters').on('click', function(e) { + e.preventDefault(); + const target = e.target; + const $form = $(target).parents('.js-filter-form'); + const baseIssuesUrl = target.href; + + $form.attr('action', baseIssuesUrl); + gl.utils.visitUrl(baseIssuesUrl); + }); + }, + initChecks: function() { + this.issuableBulkActions = $('.bulk-update').data('bulkActions'); + $('.check_all_issues').off('click').on('click', function() { + $('.selected_issue').prop('checked', this.checked); + return Issuable.checkChanged(); + }); + return $('.selected_issue').off('change').on('change', Issuable.checkChanged.bind(this)); + }, + checkChanged: function() { + const $checkedIssues = $('.selected_issue:checked'); + const $updateIssuesIds = $('#update_issuable_ids'); + const $issuesOtherFilters = $('.issues-other-filters'); + const $issuesBulkUpdate = $('.issues_bulk_update'); + + this.issuableBulkActions.willUpdateLabels = false; + this.issuableBulkActions.setOriginalDropdownData(); + + if ($checkedIssues.length > 0) { + const ids = $.map($checkedIssues, function(value) { + return $(value).data('id'); + }); + $updateIssuesIds.val(ids); + $issuesOtherFilters.hide(); + $issuesBulkUpdate.show(); + } else { + $updateIssuesIds.val([]); + $issuesBulkUpdate.hide(); + $issuesOtherFilters.show(); + } + return true; + }, + + resetIncomingEmailToken: function() { + $('.incoming-email-token-reset').on('click', function(e) { + e.preventDefault(); + + $.ajax({ + type: 'PUT', + url: $('.incoming-email-token-reset').attr('href'), + dataType: 'json', + success: function(response) { + $('#issue_email').val(response.new_issue_address).focus(); + }, + beforeSend: function() { + $('.incoming-email-token-reset').text('resetting...'); + }, + complete: function() { + $('.incoming-email-token-reset').text('reset it'); + } + }); + }); + } + }; +})(window); diff --git a/app/assets/javascripts/issuable.js.es6 b/app/assets/javascripts/issuable.js.es6 deleted file mode 100644 index 3bfce32768a..00000000000 --- a/app/assets/javascripts/issuable.js.es6 +++ /dev/null @@ -1,188 +0,0 @@ -/* eslint-disable no-param-reassign, func-names, no-var, camelcase, no-unused-vars, object-shorthand, space-before-function-paren, no-return-assign, comma-dangle, consistent-return, one-var, one-var-declaration-per-line, quotes, prefer-template, prefer-arrow-callback, wrap-iife, max-len */ -/* global Issuable */ - -((global) => { - var issuable_created; - - issuable_created = false; - - global.Issuable = { - init: function() { - Issuable.initTemplates(); - Issuable.initSearch(); - Issuable.initChecks(); - Issuable.initResetFilters(); - Issuable.resetIncomingEmailToken(); - return Issuable.initLabelFilterRemove(); - }, - initTemplates: function() { - return Issuable.labelRow = _.template('<% _.each(labels, function(label){ %> <%- label.title %> <% }); %>'); - }, - initSearch: function() { - const $searchInput = $('#issuable_search'); - - Issuable.initSearchState($searchInput); - - // `immediate` param set to false debounces on the `trailing` edge, lets user finish typing - const debouncedExecSearch = _.debounce(Issuable.executeSearch, 1000, false); - - $searchInput.off('keyup').on('keyup', debouncedExecSearch); - - // ensures existing filters are preserved when manually submitted - $('#issuable_search_form').on('submit', (e) => { - e.preventDefault(); - debouncedExecSearch(e); - }); - }, - initSearchState: function($searchInput) { - const currentSearchVal = $searchInput.val(); - - Issuable.searchState = { - elem: $searchInput, - current: currentSearchVal - }; - - Issuable.maybeFocusOnSearch(); - }, - accessSearchPristine: function(set) { - // store reference to previous value to prevent search on non-mutating keyup - const state = Issuable.searchState; - const currentSearchVal = state.elem.val(); - - if (set) { - state.current = currentSearchVal; - } else { - return state.current === currentSearchVal; - } - }, - maybeFocusOnSearch: function() { - const currentSearchVal = Issuable.searchState.current; - if (currentSearchVal && currentSearchVal !== '') { - const queryLength = currentSearchVal.length; - const $searchInput = Issuable.searchState.elem; - - /* The following ensures that the cursor is initially placed at - * the end of search input when focus is applied. It accounts - * for differences in browser implementations of `setSelectionRange` - * and cursor placement for elements in focus. - */ - $searchInput.focus(); - if ($searchInput.setSelectionRange) { - $searchInput.setSelectionRange(queryLength, queryLength); - } else { - $searchInput.val(currentSearchVal); - } - } - }, - executeSearch: function(e) { - const $search = $('#issuable_search'); - const $searchName = $search.attr('name'); - const $searchValue = $search.val(); - const $filtersForm = $('.js-filter-form'); - const $input = $(`input[name='${$searchName}']`, $filtersForm); - const isPristine = Issuable.accessSearchPristine(); - - if (isPristine) { - return; - } - - if (!$input.length) { - $filtersForm.append(``); - } else { - $input.val($searchValue); - } - - Issuable.filterResults($filtersForm); - }, - initLabelFilterRemove: function() { - return $(document).off('click', '.js-label-filter-remove').on('click', '.js-label-filter-remove', function(e) { - var $button; - $button = $(this); - // Remove the label input box - $('input[name="label_name[]"]').filter(function() { - return this.value === $button.data('label'); - }).remove(); - // Submit the form to get new data - Issuable.filterResults($('.filter-form')); - }); - }, - filterResults: (function(_this) { - return function(form) { - var formAction, formData, issuesUrl; - formData = form.serializeArray(); - formData = formData.filter(function(data) { - return data.value !== ''; - }); - formData = $.param(formData); - formAction = form.attr('action'); - issuesUrl = formAction; - issuesUrl += "" + (formAction.indexOf('?') === -1 ? '?' : '&'); - issuesUrl += formData; - return gl.utils.visitUrl(issuesUrl); - }; - })(this), - initResetFilters: function() { - $('.reset-filters').on('click', function(e) { - e.preventDefault(); - const target = e.target; - const $form = $(target).parents('.js-filter-form'); - const baseIssuesUrl = target.href; - - $form.attr('action', baseIssuesUrl); - gl.utils.visitUrl(baseIssuesUrl); - }); - }, - initChecks: function() { - this.issuableBulkActions = $('.bulk-update').data('bulkActions'); - $('.check_all_issues').off('click').on('click', function() { - $('.selected_issue').prop('checked', this.checked); - return Issuable.checkChanged(); - }); - return $('.selected_issue').off('change').on('change', Issuable.checkChanged.bind(this)); - }, - checkChanged: function() { - const $checkedIssues = $('.selected_issue:checked'); - const $updateIssuesIds = $('#update_issuable_ids'); - const $issuesOtherFilters = $('.issues-other-filters'); - const $issuesBulkUpdate = $('.issues_bulk_update'); - - this.issuableBulkActions.willUpdateLabels = false; - this.issuableBulkActions.setOriginalDropdownData(); - - if ($checkedIssues.length > 0) { - const ids = $.map($checkedIssues, function(value) { - return $(value).data('id'); - }); - $updateIssuesIds.val(ids); - $issuesOtherFilters.hide(); - $issuesBulkUpdate.show(); - } else { - $updateIssuesIds.val([]); - $issuesBulkUpdate.hide(); - $issuesOtherFilters.show(); - } - return true; - }, - - resetIncomingEmailToken: function() { - $('.incoming-email-token-reset').on('click', function(e) { - e.preventDefault(); - - $.ajax({ - type: 'PUT', - url: $('.incoming-email-token-reset').attr('href'), - dataType: 'json', - success: function(response) { - $('#issue_email').val(response.new_issue_address).focus(); - }, - beforeSend: function() { - $('.incoming-email-token-reset').text('resetting...'); - }, - complete: function() { - $('.incoming-email-token-reset').text('reset it'); - } - }); - }); - } - }; -})(window); diff --git a/app/assets/javascripts/issuable/issuable_bundle.js b/app/assets/javascripts/issuable/issuable_bundle.js new file mode 100644 index 00000000000..e927cc0077c --- /dev/null +++ b/app/assets/javascripts/issuable/issuable_bundle.js @@ -0,0 +1 @@ +require('./time_tracking/time_tracking_bundle'); diff --git a/app/assets/javascripts/issuable/issuable_bundle.js.es6 b/app/assets/javascripts/issuable/issuable_bundle.js.es6 deleted file mode 100644 index e927cc0077c..00000000000 --- a/app/assets/javascripts/issuable/issuable_bundle.js.es6 +++ /dev/null @@ -1 +0,0 @@ -require('./time_tracking/time_tracking_bundle'); diff --git a/app/assets/javascripts/issuable/time_tracking/components/collapsed_state.js b/app/assets/javascripts/issuable/time_tracking/components/collapsed_state.js new file mode 100644 index 00000000000..357b3487ca9 --- /dev/null +++ b/app/assets/javascripts/issuable/time_tracking/components/collapsed_state.js @@ -0,0 +1,42 @@ +/* global Vue */ +import stopwatchSvg from 'icons/_icon_stopwatch.svg'; + +require('../../../lib/utils/pretty_time'); + +(() => { + Vue.component('time-tracking-collapsed-state', { + name: 'time-tracking-collapsed-state', + props: [ + 'showComparisonState', + 'showSpentOnlyState', + 'showEstimateOnlyState', + 'showNoTimeTrackingState', + 'timeSpentHumanReadable', + 'timeEstimateHumanReadable', + ], + methods: { + abbreviateTime(timeStr) { + return gl.utils.prettyTime.abbreviateTime(timeStr); + }, + }, + template: ` + + `, + }); +})(); diff --git a/app/assets/javascripts/issuable/time_tracking/components/collapsed_state.js.es6 b/app/assets/javascripts/issuable/time_tracking/components/collapsed_state.js.es6 deleted file mode 100644 index 357b3487ca9..00000000000 --- a/app/assets/javascripts/issuable/time_tracking/components/collapsed_state.js.es6 +++ /dev/null @@ -1,42 +0,0 @@ -/* global Vue */ -import stopwatchSvg from 'icons/_icon_stopwatch.svg'; - -require('../../../lib/utils/pretty_time'); - -(() => { - Vue.component('time-tracking-collapsed-state', { - name: 'time-tracking-collapsed-state', - props: [ - 'showComparisonState', - 'showSpentOnlyState', - 'showEstimateOnlyState', - 'showNoTimeTrackingState', - 'timeSpentHumanReadable', - 'timeEstimateHumanReadable', - ], - methods: { - abbreviateTime(timeStr) { - return gl.utils.prettyTime.abbreviateTime(timeStr); - }, - }, - template: ` - - `, - }); -})(); diff --git a/app/assets/javascripts/issuable/time_tracking/components/comparison_pane.js b/app/assets/javascripts/issuable/time_tracking/components/comparison_pane.js new file mode 100644 index 00000000000..750468c679b --- /dev/null +++ b/app/assets/javascripts/issuable/time_tracking/components/comparison_pane.js @@ -0,0 +1,69 @@ +/* global Vue */ +require('../../../lib/utils/pretty_time'); + +(() => { + const prettyTime = gl.utils.prettyTime; + + Vue.component('time-tracking-comparison-pane', { + name: 'time-tracking-comparison-pane', + props: [ + 'timeSpent', + 'timeEstimate', + 'timeSpentHumanReadable', + 'timeEstimateHumanReadable', + ], + computed: { + parsedRemaining() { + const diffSeconds = this.timeEstimate - this.timeSpent; + return prettyTime.parseSeconds(diffSeconds); + }, + timeRemainingHumanReadable() { + return prettyTime.stringifyTime(this.parsedRemaining); + }, + timeRemainingTooltip() { + const prefix = this.timeRemainingMinutes < 0 ? 'Over by' : 'Time remaining:'; + return `${prefix} ${this.timeRemainingHumanReadable}`; + }, + /* Diff values for comparison meter */ + timeRemainingMinutes() { + return this.timeEstimate - this.timeSpent; + }, + timeRemainingPercent() { + return `${Math.floor((this.timeSpent / this.timeEstimate) * 100)}%`; + }, + timeRemainingStatusClass() { + return this.timeEstimate >= this.timeSpent ? 'within_estimate' : 'over_estimate'; + }, + /* Parsed time values */ + parsedEstimate() { + return prettyTime.parseSeconds(this.timeEstimate); + }, + parsedSpent() { + return prettyTime.parseSeconds(this.timeSpent); + }, + }, + template: ` +
    +
    +
    +
    +
    +
    +
    + Spent + {{ timeSpentHumanReadable }} +
    +
    + Est + {{ timeEstimateHumanReadable }} +
    +
    +
    +
    + `, + }); +})(); diff --git a/app/assets/javascripts/issuable/time_tracking/components/comparison_pane.js.es6 b/app/assets/javascripts/issuable/time_tracking/components/comparison_pane.js.es6 deleted file mode 100644 index 750468c679b..00000000000 --- a/app/assets/javascripts/issuable/time_tracking/components/comparison_pane.js.es6 +++ /dev/null @@ -1,69 +0,0 @@ -/* global Vue */ -require('../../../lib/utils/pretty_time'); - -(() => { - const prettyTime = gl.utils.prettyTime; - - Vue.component('time-tracking-comparison-pane', { - name: 'time-tracking-comparison-pane', - props: [ - 'timeSpent', - 'timeEstimate', - 'timeSpentHumanReadable', - 'timeEstimateHumanReadable', - ], - computed: { - parsedRemaining() { - const diffSeconds = this.timeEstimate - this.timeSpent; - return prettyTime.parseSeconds(diffSeconds); - }, - timeRemainingHumanReadable() { - return prettyTime.stringifyTime(this.parsedRemaining); - }, - timeRemainingTooltip() { - const prefix = this.timeRemainingMinutes < 0 ? 'Over by' : 'Time remaining:'; - return `${prefix} ${this.timeRemainingHumanReadable}`; - }, - /* Diff values for comparison meter */ - timeRemainingMinutes() { - return this.timeEstimate - this.timeSpent; - }, - timeRemainingPercent() { - return `${Math.floor((this.timeSpent / this.timeEstimate) * 100)}%`; - }, - timeRemainingStatusClass() { - return this.timeEstimate >= this.timeSpent ? 'within_estimate' : 'over_estimate'; - }, - /* Parsed time values */ - parsedEstimate() { - return prettyTime.parseSeconds(this.timeEstimate); - }, - parsedSpent() { - return prettyTime.parseSeconds(this.timeSpent); - }, - }, - template: ` -
    -
    -
    -
    -
    -
    -
    - Spent - {{ timeSpentHumanReadable }} -
    -
    - Est - {{ timeEstimateHumanReadable }} -
    -
    -
    -
    - `, - }); -})(); diff --git a/app/assets/javascripts/issuable/time_tracking/components/estimate_only_pane.js b/app/assets/javascripts/issuable/time_tracking/components/estimate_only_pane.js new file mode 100644 index 00000000000..309e9f2f9ef --- /dev/null +++ b/app/assets/javascripts/issuable/time_tracking/components/estimate_only_pane.js @@ -0,0 +1,13 @@ +/* global Vue */ +(() => { + Vue.component('time-tracking-estimate-only-pane', { + name: 'time-tracking-estimate-only-pane', + props: ['timeEstimateHumanReadable'], + template: ` +
    + Estimated: + {{ timeEstimateHumanReadable }} +
    + `, + }); +})(); diff --git a/app/assets/javascripts/issuable/time_tracking/components/estimate_only_pane.js.es6 b/app/assets/javascripts/issuable/time_tracking/components/estimate_only_pane.js.es6 deleted file mode 100644 index 309e9f2f9ef..00000000000 --- a/app/assets/javascripts/issuable/time_tracking/components/estimate_only_pane.js.es6 +++ /dev/null @@ -1,13 +0,0 @@ -/* global Vue */ -(() => { - Vue.component('time-tracking-estimate-only-pane', { - name: 'time-tracking-estimate-only-pane', - props: ['timeEstimateHumanReadable'], - template: ` -
    - Estimated: - {{ timeEstimateHumanReadable }} -
    - `, - }); -})(); diff --git a/app/assets/javascripts/issuable/time_tracking/components/help_state.js b/app/assets/javascripts/issuable/time_tracking/components/help_state.js new file mode 100644 index 00000000000..d7ced6d7151 --- /dev/null +++ b/app/assets/javascripts/issuable/time_tracking/components/help_state.js @@ -0,0 +1,24 @@ +/* global Vue */ +(() => { + Vue.component('time-tracking-help-state', { + name: 'time-tracking-help-state', + props: ['docsUrl'], + template: ` +
    +
    +

    Track time with slash commands

    +

    Slash commands can be used in the issues description and comment boxes.

    +

    + /estimate + will update the estimated time with the latest command. +

    +

    + /spend + will update the sum of the time spent. +

    + Learn more +
    +
    + `, + }); +})(); diff --git a/app/assets/javascripts/issuable/time_tracking/components/help_state.js.es6 b/app/assets/javascripts/issuable/time_tracking/components/help_state.js.es6 deleted file mode 100644 index d7ced6d7151..00000000000 --- a/app/assets/javascripts/issuable/time_tracking/components/help_state.js.es6 +++ /dev/null @@ -1,24 +0,0 @@ -/* global Vue */ -(() => { - Vue.component('time-tracking-help-state', { - name: 'time-tracking-help-state', - props: ['docsUrl'], - template: ` -
    -
    -

    Track time with slash commands

    -

    Slash commands can be used in the issues description and comment boxes.

    -

    - /estimate - will update the estimated time with the latest command. -

    -

    - /spend - will update the sum of the time spent. -

    - Learn more -
    -
    - `, - }); -})(); diff --git a/app/assets/javascripts/issuable/time_tracking/components/no_tracking_pane.js b/app/assets/javascripts/issuable/time_tracking/components/no_tracking_pane.js new file mode 100644 index 00000000000..1d2ca643b5b --- /dev/null +++ b/app/assets/javascripts/issuable/time_tracking/components/no_tracking_pane.js @@ -0,0 +1,11 @@ +/* global Vue */ +(() => { + Vue.component('time-tracking-no-tracking-pane', { + name: 'time-tracking-no-tracking-pane', + template: ` +
    + No estimate or time spent +
    + `, + }); +})(); diff --git a/app/assets/javascripts/issuable/time_tracking/components/no_tracking_pane.js.es6 b/app/assets/javascripts/issuable/time_tracking/components/no_tracking_pane.js.es6 deleted file mode 100644 index 1d2ca643b5b..00000000000 --- a/app/assets/javascripts/issuable/time_tracking/components/no_tracking_pane.js.es6 +++ /dev/null @@ -1,11 +0,0 @@ -/* global Vue */ -(() => { - Vue.component('time-tracking-no-tracking-pane', { - name: 'time-tracking-no-tracking-pane', - template: ` -
    - No estimate or time spent -
    - `, - }); -})(); diff --git a/app/assets/javascripts/issuable/time_tracking/components/spent_only_pane.js b/app/assets/javascripts/issuable/time_tracking/components/spent_only_pane.js new file mode 100644 index 00000000000..ed283fec3c3 --- /dev/null +++ b/app/assets/javascripts/issuable/time_tracking/components/spent_only_pane.js @@ -0,0 +1,13 @@ +/* global Vue */ +(() => { + Vue.component('time-tracking-spent-only-pane', { + name: 'time-tracking-spent-only-pane', + props: ['timeSpentHumanReadable'], + template: ` +
    + Spent: + {{ timeSpentHumanReadable }} +
    + `, + }); +})(); diff --git a/app/assets/javascripts/issuable/time_tracking/components/spent_only_pane.js.es6 b/app/assets/javascripts/issuable/time_tracking/components/spent_only_pane.js.es6 deleted file mode 100644 index ed283fec3c3..00000000000 --- a/app/assets/javascripts/issuable/time_tracking/components/spent_only_pane.js.es6 +++ /dev/null @@ -1,13 +0,0 @@ -/* global Vue */ -(() => { - Vue.component('time-tracking-spent-only-pane', { - name: 'time-tracking-spent-only-pane', - props: ['timeSpentHumanReadable'], - template: ` -
    - Spent: - {{ timeSpentHumanReadable }} -
    - `, - }); -})(); diff --git a/app/assets/javascripts/issuable/time_tracking/components/time_tracker.js b/app/assets/javascripts/issuable/time_tracking/components/time_tracker.js new file mode 100644 index 00000000000..1fae2d62b14 --- /dev/null +++ b/app/assets/javascripts/issuable/time_tracking/components/time_tracker.js @@ -0,0 +1,117 @@ +/* global Vue */ + +require('./help_state'); +require('./collapsed_state'); +require('./spent_only_pane'); +require('./no_tracking_pane'); +require('./estimate_only_pane'); +require('./comparison_pane'); + +(() => { + Vue.component('issuable-time-tracker', { + name: 'issuable-time-tracker', + props: [ + 'time_estimate', + 'time_spent', + 'human_time_estimate', + 'human_time_spent', + 'docsUrl', + ], + data() { + return { + showHelp: false, + }; + }, + computed: { + timeSpent() { + return this.time_spent; + }, + timeEstimate() { + return this.time_estimate; + }, + timeEstimateHumanReadable() { + return this.human_time_estimate; + }, + timeSpentHumanReadable() { + return this.human_time_spent; + }, + hasTimeSpent() { + return !!this.timeSpent; + }, + hasTimeEstimate() { + return !!this.timeEstimate; + }, + showComparisonState() { + return this.hasTimeEstimate && this.hasTimeSpent; + }, + showEstimateOnlyState() { + return this.hasTimeEstimate && !this.hasTimeSpent; + }, + showSpentOnlyState() { + return this.hasTimeSpent && !this.hasTimeEstimate; + }, + showNoTimeTrackingState() { + return !this.hasTimeEstimate && !this.hasTimeSpent; + }, + showHelpState() { + return !!this.showHelp; + }, + }, + methods: { + toggleHelpState(show) { + this.showHelp = show; + }, + }, + template: ` +
    + + +
    + Time tracking +
    + +
    +
    + +
    +
    +
    + + + + + + + + + + + + +
    +
    + `, + }); +})(); diff --git a/app/assets/javascripts/issuable/time_tracking/components/time_tracker.js.es6 b/app/assets/javascripts/issuable/time_tracking/components/time_tracker.js.es6 deleted file mode 100644 index 1fae2d62b14..00000000000 --- a/app/assets/javascripts/issuable/time_tracking/components/time_tracker.js.es6 +++ /dev/null @@ -1,117 +0,0 @@ -/* global Vue */ - -require('./help_state'); -require('./collapsed_state'); -require('./spent_only_pane'); -require('./no_tracking_pane'); -require('./estimate_only_pane'); -require('./comparison_pane'); - -(() => { - Vue.component('issuable-time-tracker', { - name: 'issuable-time-tracker', - props: [ - 'time_estimate', - 'time_spent', - 'human_time_estimate', - 'human_time_spent', - 'docsUrl', - ], - data() { - return { - showHelp: false, - }; - }, - computed: { - timeSpent() { - return this.time_spent; - }, - timeEstimate() { - return this.time_estimate; - }, - timeEstimateHumanReadable() { - return this.human_time_estimate; - }, - timeSpentHumanReadable() { - return this.human_time_spent; - }, - hasTimeSpent() { - return !!this.timeSpent; - }, - hasTimeEstimate() { - return !!this.timeEstimate; - }, - showComparisonState() { - return this.hasTimeEstimate && this.hasTimeSpent; - }, - showEstimateOnlyState() { - return this.hasTimeEstimate && !this.hasTimeSpent; - }, - showSpentOnlyState() { - return this.hasTimeSpent && !this.hasTimeEstimate; - }, - showNoTimeTrackingState() { - return !this.hasTimeEstimate && !this.hasTimeSpent; - }, - showHelpState() { - return !!this.showHelp; - }, - }, - methods: { - toggleHelpState(show) { - this.showHelp = show; - }, - }, - template: ` -
    - - -
    - Time tracking -
    - -
    -
    - -
    -
    -
    - - - - - - - - - - - - -
    -
    - `, - }); -})(); diff --git a/app/assets/javascripts/issuable/time_tracking/time_tracking_bundle.js b/app/assets/javascripts/issuable/time_tracking/time_tracking_bundle.js new file mode 100644 index 00000000000..0134b7cb6f3 --- /dev/null +++ b/app/assets/javascripts/issuable/time_tracking/time_tracking_bundle.js @@ -0,0 +1,65 @@ +/* global Vue */ + +window.Vue = require('vue'); +window.Vue.use(require('vue-resource')); +require('./components/time_tracker'); +require('../../smart_interval'); +require('../../subbable_resource'); + +(() => { + /* This Vue instance represents what will become the parent instance for the + * sidebar. It will be responsible for managing `issuable` state and propagating + * changes to sidebar components. We will want to create a separate service to + * interface with the server at that point. + */ + + class IssuableTimeTracking { + constructor(issuableJSON) { + const parsedIssuable = JSON.parse(issuableJSON); + return this.initComponent(parsedIssuable); + } + + initComponent(parsedIssuable) { + this.parentInstance = new Vue({ + el: '#issuable-time-tracker', + data: { + issuable: parsedIssuable, + }, + methods: { + fetchIssuable() { + return gl.IssuableResource.get.call(gl.IssuableResource, { + type: 'GET', + url: gl.IssuableResource.endpoint, + }); + }, + updateState(data) { + this.issuable = data; + }, + subscribeToUpdates() { + gl.IssuableResource.subscribe(data => this.updateState(data)); + }, + listenForSlashCommands() { + $(document).on('ajax:success', '.gfm-form', (e, data) => { + const subscribedCommands = ['spend_time', 'time_estimate']; + const changedCommands = data.commands_changes + ? Object.keys(data.commands_changes) + : []; + if (changedCommands && _.intersection(subscribedCommands, changedCommands).length) { + this.fetchIssuable(); + } + }); + }, + }, + created() { + this.fetchIssuable(); + }, + mounted() { + this.subscribeToUpdates(); + this.listenForSlashCommands(); + }, + }); + } + } + + gl.IssuableTimeTracking = IssuableTimeTracking; +})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/issuable/time_tracking/time_tracking_bundle.js.es6 b/app/assets/javascripts/issuable/time_tracking/time_tracking_bundle.js.es6 deleted file mode 100644 index 0134b7cb6f3..00000000000 --- a/app/assets/javascripts/issuable/time_tracking/time_tracking_bundle.js.es6 +++ /dev/null @@ -1,65 +0,0 @@ -/* global Vue */ - -window.Vue = require('vue'); -window.Vue.use(require('vue-resource')); -require('./components/time_tracker'); -require('../../smart_interval'); -require('../../subbable_resource'); - -(() => { - /* This Vue instance represents what will become the parent instance for the - * sidebar. It will be responsible for managing `issuable` state and propagating - * changes to sidebar components. We will want to create a separate service to - * interface with the server at that point. - */ - - class IssuableTimeTracking { - constructor(issuableJSON) { - const parsedIssuable = JSON.parse(issuableJSON); - return this.initComponent(parsedIssuable); - } - - initComponent(parsedIssuable) { - this.parentInstance = new Vue({ - el: '#issuable-time-tracker', - data: { - issuable: parsedIssuable, - }, - methods: { - fetchIssuable() { - return gl.IssuableResource.get.call(gl.IssuableResource, { - type: 'GET', - url: gl.IssuableResource.endpoint, - }); - }, - updateState(data) { - this.issuable = data; - }, - subscribeToUpdates() { - gl.IssuableResource.subscribe(data => this.updateState(data)); - }, - listenForSlashCommands() { - $(document).on('ajax:success', '.gfm-form', (e, data) => { - const subscribedCommands = ['spend_time', 'time_estimate']; - const changedCommands = data.commands_changes - ? Object.keys(data.commands_changes) - : []; - if (changedCommands && _.intersection(subscribedCommands, changedCommands).length) { - this.fetchIssuable(); - } - }); - }, - }, - created() { - this.fetchIssuable(); - }, - mounted() { - this.subscribeToUpdates(); - this.listenForSlashCommands(); - }, - }); - } - } - - gl.IssuableTimeTracking = IssuableTimeTracking; -})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/issues_bulk_assignment.js b/app/assets/javascripts/issues_bulk_assignment.js new file mode 100644 index 00000000000..e0ebd36a65c --- /dev/null +++ b/app/assets/javascripts/issues_bulk_assignment.js @@ -0,0 +1,163 @@ +/* eslint-disable comma-dangle, quotes, consistent-return, func-names, array-callback-return, space-before-function-paren, prefer-arrow-callback, max-len, no-unused-expressions, no-sequences, no-underscore-dangle, no-unused-vars, no-param-reassign */ +/* global Issuable */ +/* global Flash */ + +((global) => { + class IssuableBulkActions { + constructor({ container, form, issues, prefixId } = {}) { + this.prefixId = prefixId || 'issue_'; + this.form = form || this.getElement('.bulk-update'); + this.$labelDropdown = this.form.find('.js-label-select'); + this.issues = issues || this.getElement('.issues-list .issue'); + this.form.data('bulkActions', this); + this.willUpdateLabels = false; + this.bindEvents(); + // Fixes bulk-assign not working when navigating through pages + Issuable.initChecks(); + } + + bindEvents() { + return this.form.off('submit').on('submit', this.onFormSubmit.bind(this)); + } + + onFormSubmit(e) { + e.preventDefault(); + return this.submit(); + } + + submit() { + const _this = this; + const xhr = $.ajax({ + url: this.form.attr('action'), + method: this.form.attr('method'), + dataType: 'JSON', + data: this.getFormDataAsObject() + }); + xhr.done(() => window.location.reload()); + xhr.fail(() => new Flash("Issue update failed")); + return xhr.always(this.onFormSubmitAlways.bind(this)); + } + + onFormSubmitAlways() { + return this.form.find('[type="submit"]').enable(); + } + + getSelectedIssues() { + return this.issues.has('.selected_issue:checked'); + } + + getLabelsFromSelection() { + const labels = []; + this.getSelectedIssues().map(function() { + const labelsData = $(this).data('labels'); + if (labelsData) { + return labelsData.map(function(labelId) { + if (labels.indexOf(labelId) === -1) { + return labels.push(labelId); + } + }); + } + }); + return labels; + } + + /** + * Will return only labels that were marked previously and the user has unmarked + * @return {Array} Label IDs + */ + + getUnmarkedIndeterminedLabels() { + const result = []; + const labelsToKeep = this.$labelDropdown.data('indeterminate'); + + this.getLabelsFromSelection().forEach((id) => { + if (labelsToKeep.indexOf(id) === -1) { + result.push(id); + } + }); + + return result; + } + + /** + * Simple form serialization, it will return just what we need + * Returns key/value pairs from form data + */ + + getFormDataAsObject() { + const formData = { + update: { + state_event: this.form.find('input[name="update[state_event]"]').val(), + assignee_id: this.form.find('input[name="update[assignee_id]"]').val(), + milestone_id: this.form.find('input[name="update[milestone_id]"]').val(), + issuable_ids: this.form.find('input[name="update[issuable_ids]"]').val(), + subscription_event: this.form.find('input[name="update[subscription_event]"]').val(), + add_label_ids: [], + remove_label_ids: [] + } + }; + if (this.willUpdateLabels) { + formData.update.add_label_ids = this.$labelDropdown.data('marked'); + formData.update.remove_label_ids = this.$labelDropdown.data('unmarked'); + } + return formData; + } + + setOriginalDropdownData() { + const $labelSelect = $('.bulk-update .js-label-select'); + $labelSelect.data('common', this.getOriginalCommonIds()); + $labelSelect.data('marked', this.getOriginalMarkedIds()); + $labelSelect.data('indeterminate', this.getOriginalIndeterminateIds()); + } + + // From issuable's initial bulk selection + getOriginalCommonIds() { + const labelIds = []; + + this.getElement('.selected_issue:checked').each((i, el) => { + labelIds.push(this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels')); + }); + return _.intersection.apply(this, labelIds); + } + + // From issuable's initial bulk selection + getOriginalMarkedIds() { + const labelIds = []; + this.getElement('.selected_issue:checked').each((i, el) => { + labelIds.push(this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels')); + }); + return _.intersection.apply(this, labelIds); + } + + // From issuable's initial bulk selection + getOriginalIndeterminateIds() { + const uniqueIds = []; + const labelIds = []; + let issuableLabels = []; + + // Collect unique label IDs for all checked issues + this.getElement('.selected_issue:checked').each((i, el) => { + issuableLabels = this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels'); + issuableLabels.forEach((labelId) => { + // Store unique IDs + if (uniqueIds.indexOf(labelId) === -1) { + uniqueIds.push(labelId); + } + }); + // Store array of IDs per issuable + labelIds.push(issuableLabels); + }); + // Add uniqueIds to add it as argument for _.intersection + labelIds.unshift(uniqueIds); + // Return IDs that are present but not in all selected issueables + return _.difference(uniqueIds, _.intersection.apply(this, labelIds)); + } + + getElement(selector) { + this.scopeEl = this.scopeEl || $('.content'); + return this.scopeEl.find(selector); + } + } + + global.IssuableBulkActions = IssuableBulkActions; +})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/issues_bulk_assignment.js.es6 b/app/assets/javascripts/issues_bulk_assignment.js.es6 deleted file mode 100644 index e0ebd36a65c..00000000000 --- a/app/assets/javascripts/issues_bulk_assignment.js.es6 +++ /dev/null @@ -1,163 +0,0 @@ -/* eslint-disable comma-dangle, quotes, consistent-return, func-names, array-callback-return, space-before-function-paren, prefer-arrow-callback, max-len, no-unused-expressions, no-sequences, no-underscore-dangle, no-unused-vars, no-param-reassign */ -/* global Issuable */ -/* global Flash */ - -((global) => { - class IssuableBulkActions { - constructor({ container, form, issues, prefixId } = {}) { - this.prefixId = prefixId || 'issue_'; - this.form = form || this.getElement('.bulk-update'); - this.$labelDropdown = this.form.find('.js-label-select'); - this.issues = issues || this.getElement('.issues-list .issue'); - this.form.data('bulkActions', this); - this.willUpdateLabels = false; - this.bindEvents(); - // Fixes bulk-assign not working when navigating through pages - Issuable.initChecks(); - } - - bindEvents() { - return this.form.off('submit').on('submit', this.onFormSubmit.bind(this)); - } - - onFormSubmit(e) { - e.preventDefault(); - return this.submit(); - } - - submit() { - const _this = this; - const xhr = $.ajax({ - url: this.form.attr('action'), - method: this.form.attr('method'), - dataType: 'JSON', - data: this.getFormDataAsObject() - }); - xhr.done(() => window.location.reload()); - xhr.fail(() => new Flash("Issue update failed")); - return xhr.always(this.onFormSubmitAlways.bind(this)); - } - - onFormSubmitAlways() { - return this.form.find('[type="submit"]').enable(); - } - - getSelectedIssues() { - return this.issues.has('.selected_issue:checked'); - } - - getLabelsFromSelection() { - const labels = []; - this.getSelectedIssues().map(function() { - const labelsData = $(this).data('labels'); - if (labelsData) { - return labelsData.map(function(labelId) { - if (labels.indexOf(labelId) === -1) { - return labels.push(labelId); - } - }); - } - }); - return labels; - } - - /** - * Will return only labels that were marked previously and the user has unmarked - * @return {Array} Label IDs - */ - - getUnmarkedIndeterminedLabels() { - const result = []; - const labelsToKeep = this.$labelDropdown.data('indeterminate'); - - this.getLabelsFromSelection().forEach((id) => { - if (labelsToKeep.indexOf(id) === -1) { - result.push(id); - } - }); - - return result; - } - - /** - * Simple form serialization, it will return just what we need - * Returns key/value pairs from form data - */ - - getFormDataAsObject() { - const formData = { - update: { - state_event: this.form.find('input[name="update[state_event]"]').val(), - assignee_id: this.form.find('input[name="update[assignee_id]"]').val(), - milestone_id: this.form.find('input[name="update[milestone_id]"]').val(), - issuable_ids: this.form.find('input[name="update[issuable_ids]"]').val(), - subscription_event: this.form.find('input[name="update[subscription_event]"]').val(), - add_label_ids: [], - remove_label_ids: [] - } - }; - if (this.willUpdateLabels) { - formData.update.add_label_ids = this.$labelDropdown.data('marked'); - formData.update.remove_label_ids = this.$labelDropdown.data('unmarked'); - } - return formData; - } - - setOriginalDropdownData() { - const $labelSelect = $('.bulk-update .js-label-select'); - $labelSelect.data('common', this.getOriginalCommonIds()); - $labelSelect.data('marked', this.getOriginalMarkedIds()); - $labelSelect.data('indeterminate', this.getOriginalIndeterminateIds()); - } - - // From issuable's initial bulk selection - getOriginalCommonIds() { - const labelIds = []; - - this.getElement('.selected_issue:checked').each((i, el) => { - labelIds.push(this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels')); - }); - return _.intersection.apply(this, labelIds); - } - - // From issuable's initial bulk selection - getOriginalMarkedIds() { - const labelIds = []; - this.getElement('.selected_issue:checked').each((i, el) => { - labelIds.push(this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels')); - }); - return _.intersection.apply(this, labelIds); - } - - // From issuable's initial bulk selection - getOriginalIndeterminateIds() { - const uniqueIds = []; - const labelIds = []; - let issuableLabels = []; - - // Collect unique label IDs for all checked issues - this.getElement('.selected_issue:checked').each((i, el) => { - issuableLabels = this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels'); - issuableLabels.forEach((labelId) => { - // Store unique IDs - if (uniqueIds.indexOf(labelId) === -1) { - uniqueIds.push(labelId); - } - }); - // Store array of IDs per issuable - labelIds.push(issuableLabels); - }); - // Add uniqueIds to add it as argument for _.intersection - labelIds.unshift(uniqueIds); - // Return IDs that are present but not in all selected issueables - return _.difference(uniqueIds, _.intersection.apply(this, labelIds)); - } - - getElement(selector) { - this.scopeEl = this.scopeEl || $('.content'); - return this.scopeEl.find(selector); - } - } - - global.IssuableBulkActions = IssuableBulkActions; -})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/label_manager.js b/app/assets/javascripts/label_manager.js new file mode 100644 index 00000000000..38b2eb9ff14 --- /dev/null +++ b/app/assets/javascripts/label_manager.js @@ -0,0 +1,118 @@ +/* eslint-disable comma-dangle, class-methods-use-this, no-underscore-dangle, no-param-reassign, no-unused-vars, consistent-return, func-names, space-before-function-paren, max-len */ +/* global Flash */ +/* global Sortable */ + +((global) => { + class LabelManager { + constructor({ togglePriorityButton, prioritizedLabels, otherLabels } = {}) { + this.togglePriorityButton = togglePriorityButton || $('.js-toggle-priority'); + this.prioritizedLabels = prioritizedLabels || $('.js-prioritized-labels'); + this.otherLabels = otherLabels || $('.js-other-labels'); + this.errorMessage = 'Unable to update label prioritization at this time'; + this.emptyState = document.querySelector('#js-priority-labels-empty-state'); + this.sortable = Sortable.create(this.prioritizedLabels.get(0), { + filter: '.empty-message', + forceFallback: true, + fallbackClass: 'is-dragging', + dataIdAttr: 'data-id', + onUpdate: this.onPrioritySortUpdate.bind(this), + }); + this.bindEvents(); + } + + bindEvents() { + return this.togglePriorityButton.on('click', this, this.onTogglePriorityClick); + } + + onTogglePriorityClick(e) { + e.preventDefault(); + const _this = e.data; + const $btn = $(e.currentTarget); + const $label = $(`#${$btn.data('domId')}`); + const action = $btn.parents('.js-prioritized-labels').length ? 'remove' : 'add'; + const $tooltip = $(`#${$btn.find('.has-tooltip:visible').attr('aria-describedby')}`); + $tooltip.tooltip('destroy'); + _this.toggleLabelPriority($label, action); + _this.toggleEmptyState($label, $btn, action); + } + + toggleEmptyState($label, $btn, action) { + this.emptyState.classList.toggle('hidden', !!this.prioritizedLabels[0].querySelector(':scope > li')); + } + + toggleLabelPriority($label, action, persistState) { + if (persistState == null) { + persistState = true; + } + let xhr; + const _this = this; + const url = $label.find('.js-toggle-priority').data('url'); + let $target = this.prioritizedLabels; + let $from = this.otherLabels; + if (action === 'remove') { + $target = this.otherLabels; + $from = this.prioritizedLabels; + } + $label.detach().appendTo($target); + if ($from.find('li').length) { + $from.find('.empty-message').removeClass('hidden'); + } + if ($target.find('> li:not(.empty-message)').length) { + $target.find('.empty-message').addClass('hidden'); + } + // Return if we are not persisting state + if (!persistState) { + return; + } + if (action === 'remove') { + xhr = $.ajax({ + url, + type: 'DELETE' + }); + // Restore empty message + if (!$from.find('li').length) { + $from.find('.empty-message').removeClass('hidden'); + } + } else { + xhr = this.savePrioritySort($label, action); + } + return xhr.fail(this.rollbackLabelPosition.bind(this, $label, action)); + } + + onPrioritySortUpdate() { + const xhr = this.savePrioritySort(); + return xhr.fail(function() { + return new Flash(this.errorMessage, 'alert'); + }); + } + + savePrioritySort() { + return $.post({ + url: this.prioritizedLabels.data('url'), + data: { + label_ids: this.getSortedLabelsIds() + } + }); + } + + rollbackLabelPosition($label, originalAction) { + const action = originalAction === 'remove' ? 'add' : 'remove'; + this.toggleLabelPriority($label, action, false); + return new Flash(this.errorMessage, 'alert'); + } + + getSortedLabelsIds() { + const sortedIds = []; + this.prioritizedLabels.find('> li').each(function() { + const id = $(this).data('id'); + + if (id) { + sortedIds.push(id); + } + }); + return sortedIds; + } + } + + gl.LabelManager = LabelManager; +})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/label_manager.js.es6 b/app/assets/javascripts/label_manager.js.es6 deleted file mode 100644 index 38b2eb9ff14..00000000000 --- a/app/assets/javascripts/label_manager.js.es6 +++ /dev/null @@ -1,118 +0,0 @@ -/* eslint-disable comma-dangle, class-methods-use-this, no-underscore-dangle, no-param-reassign, no-unused-vars, consistent-return, func-names, space-before-function-paren, max-len */ -/* global Flash */ -/* global Sortable */ - -((global) => { - class LabelManager { - constructor({ togglePriorityButton, prioritizedLabels, otherLabels } = {}) { - this.togglePriorityButton = togglePriorityButton || $('.js-toggle-priority'); - this.prioritizedLabels = prioritizedLabels || $('.js-prioritized-labels'); - this.otherLabels = otherLabels || $('.js-other-labels'); - this.errorMessage = 'Unable to update label prioritization at this time'; - this.emptyState = document.querySelector('#js-priority-labels-empty-state'); - this.sortable = Sortable.create(this.prioritizedLabels.get(0), { - filter: '.empty-message', - forceFallback: true, - fallbackClass: 'is-dragging', - dataIdAttr: 'data-id', - onUpdate: this.onPrioritySortUpdate.bind(this), - }); - this.bindEvents(); - } - - bindEvents() { - return this.togglePriorityButton.on('click', this, this.onTogglePriorityClick); - } - - onTogglePriorityClick(e) { - e.preventDefault(); - const _this = e.data; - const $btn = $(e.currentTarget); - const $label = $(`#${$btn.data('domId')}`); - const action = $btn.parents('.js-prioritized-labels').length ? 'remove' : 'add'; - const $tooltip = $(`#${$btn.find('.has-tooltip:visible').attr('aria-describedby')}`); - $tooltip.tooltip('destroy'); - _this.toggleLabelPriority($label, action); - _this.toggleEmptyState($label, $btn, action); - } - - toggleEmptyState($label, $btn, action) { - this.emptyState.classList.toggle('hidden', !!this.prioritizedLabels[0].querySelector(':scope > li')); - } - - toggleLabelPriority($label, action, persistState) { - if (persistState == null) { - persistState = true; - } - let xhr; - const _this = this; - const url = $label.find('.js-toggle-priority').data('url'); - let $target = this.prioritizedLabels; - let $from = this.otherLabels; - if (action === 'remove') { - $target = this.otherLabels; - $from = this.prioritizedLabels; - } - $label.detach().appendTo($target); - if ($from.find('li').length) { - $from.find('.empty-message').removeClass('hidden'); - } - if ($target.find('> li:not(.empty-message)').length) { - $target.find('.empty-message').addClass('hidden'); - } - // Return if we are not persisting state - if (!persistState) { - return; - } - if (action === 'remove') { - xhr = $.ajax({ - url, - type: 'DELETE' - }); - // Restore empty message - if (!$from.find('li').length) { - $from.find('.empty-message').removeClass('hidden'); - } - } else { - xhr = this.savePrioritySort($label, action); - } - return xhr.fail(this.rollbackLabelPosition.bind(this, $label, action)); - } - - onPrioritySortUpdate() { - const xhr = this.savePrioritySort(); - return xhr.fail(function() { - return new Flash(this.errorMessage, 'alert'); - }); - } - - savePrioritySort() { - return $.post({ - url: this.prioritizedLabels.data('url'), - data: { - label_ids: this.getSortedLabelsIds() - } - }); - } - - rollbackLabelPosition($label, originalAction) { - const action = originalAction === 'remove' ? 'add' : 'remove'; - this.toggleLabelPriority($label, action, false); - return new Flash(this.errorMessage, 'alert'); - } - - getSortedLabelsIds() { - const sortedIds = []; - this.prioritizedLabels.find('> li').each(function() { - const id = $(this).data('id'); - - if (id) { - sortedIds.push(id); - } - }); - return sortedIds; - } - } - - gl.LabelManager = LabelManager; -})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/lib/utils/bootstrap_linked_tabs.js b/app/assets/javascripts/lib/utils/bootstrap_linked_tabs.js new file mode 100644 index 00000000000..2955bda1a36 --- /dev/null +++ b/app/assets/javascripts/lib/utils/bootstrap_linked_tabs.js @@ -0,0 +1,112 @@ +/** + * Linked Tabs + * + * Handles persisting and restores the current tab selection and content. + * Reusable component for static content. + * + * ### Example Markup + * + *