Commit f61c6cdd authored by Robert Speicher's avatar Robert Speicher

Merge branch 'master' into ce-to-ee-2018-03-06

parents 856900d2 2ed6b0cd
...@@ -7,6 +7,7 @@ export default class FilteredSearchBoards extends FilteredSearchManager { ...@@ -7,6 +7,7 @@ export default class FilteredSearchBoards extends FilteredSearchManager {
constructor(store, updateUrl = false, cantEdit = []) { constructor(store, updateUrl = false, cantEdit = []) {
super({ super({
page: 'boards', page: 'boards',
isGroup: true,
filteredSearchTokenKeys: FilteredSearchTokenKeysIssues, filteredSearchTokenKeys: FilteredSearchTokenKeysIssues,
stateFiltersSelector: '.issues-state-filters', stateFiltersSelector: '.issues-state-filters',
}); });
......
...@@ -125,6 +125,16 @@ export default class FilteredSearchDropdownManager { ...@@ -125,6 +125,16 @@ export default class FilteredSearchDropdownManager {
endpoint = `${endpoint}?only_group_labels=true`; endpoint = `${endpoint}?only_group_labels=true`;
} }
// EE-only
if (this.groupAncestor) {
endpoint = `${endpoint}&include_ancestor_groups=true`;
}
// EE-only
if (this.isGroupDecendent) {
endpoint = `${endpoint}&include_descendant_groups=true`;
}
return endpoint; return endpoint;
} }
......
...@@ -109,6 +109,7 @@ export default class FilteredSearchManager { ...@@ -109,6 +109,7 @@ export default class FilteredSearchManager {
page: this.page, page: this.page,
isGroup: this.isGroup, isGroup: this.isGroup,
isGroupAncestor: this.isGroupAncestor, isGroupAncestor: this.isGroupAncestor,
isGroupDecendent: this.isGroupDecendent,
filteredSearchTokenKeys: this.filteredSearchTokenKeys, filteredSearchTokenKeys: this.filteredSearchTokenKeys,
}); });
......
...@@ -8,4 +8,5 @@ export default { ...@@ -8,4 +8,5 @@ export default {
OK: 200, OK: 200,
MULTIPLE_CHOICES: 300, MULTIPLE_CHOICES: 300,
BAD_REQUEST: 400, BAD_REQUEST: 400,
NOT_FOUND: 404,
}; };
...@@ -88,7 +88,7 @@ export default { ...@@ -88,7 +88,7 @@ export default {
</script> </script>
<template> <template>
<div class="block labels"> <div class="block labels js-labels-block">
<dropdown-value-collapsed <dropdown-value-collapsed
v-if="showCreate" v-if="showCreate"
:labels="context.labels" :labels="context.labels"
...@@ -104,7 +104,7 @@ export default { ...@@ -104,7 +104,7 @@ export default {
</dropdown-value> </dropdown-value>
<div <div
v-if="canEdit" v-if="canEdit"
class="selectbox" class="selectbox js-selectbox"
style="display: none;" style="display: none;"
> >
<dropdown-hidden-input <dropdown-hidden-input
......
...@@ -35,7 +35,7 @@ export default { ...@@ -35,7 +35,7 @@ export default {
</script> </script>
<template> <template>
<div class="hide-collapsed value issuable-show-labels"> <div class="hide-collapsed value issuable-show-labels js-value">
<span <span
v-if="isEmpty" v-if="isEmpty"
class="text-secondary" class="text-secondary"
......
module Boards module Boards
class IssuesController < Boards::ApplicationController class IssuesController < Boards::ApplicationController
prepend EE::BoardsResponses
prepend EE::Boards::IssuesController
include BoardsResponses include BoardsResponses
include ControllerWithCrossProjectAccessCheck
requires_cross_project_access if: -> { board&.group_board? }
before_action :whitelist_query_limiting, only: [:index, :update] before_action :whitelist_query_limiting, only: [:index, :update]
before_action :authorize_read_issue, only: [:index] before_action :authorize_read_issue, only: [:index]
...@@ -66,11 +67,19 @@ module Boards ...@@ -66,11 +67,19 @@ module Boards
end end
def issues_finder def issues_finder
IssuesFinder.new(current_user, project_id: board_parent.id) if board.group_board?
IssuesFinder.new(current_user, group_id: board_parent.id)
else
IssuesFinder.new(current_user, project_id: board_parent.id)
end
end end
def project def project
board_parent @project ||= if board.group_board?
Project.find(issue_params[:project_id])
else
board_parent
end
end end
def move_params def move_params
......
module Boards module Boards
class ListsController < Boards::ApplicationController class ListsController < Boards::ApplicationController
prepend EE::BoardsResponses
include BoardsResponses include BoardsResponses
before_action :authorize_admin_list, only: [:create, :update, :destroy, :generate] before_action :authorize_admin_list, only: [:create, :update, :destroy, :generate]
......
module BoardsResponses module BoardsResponses
include Gitlab::Utils::StrongMemoize
def board_params
params.require(:board).permit(:name, :weight, :milestone_id, :assignee_id, label_ids: [])
end
def parent
strong_memoize(:parent) do
group? ? group : project
end
end
def boards_path
if group?
group_boards_path(parent)
else
project_boards_path(parent)
end
end
def board_path(board)
if group?
group_board_path(parent, board)
else
project_board_path(parent, board)
end
end
def group?
instance_variable_defined?(:@group)
end
def authorize_read_list def authorize_read_list
authorize_action_for!(board.parent, :read_list) ability = board.group_board? ? :read_group : :read_list
authorize_action_for!(board.parent, ability)
end end
def authorize_read_issue def authorize_read_issue
authorize_action_for!(board.parent, :read_issue) ability = board.group_board? ? :read_group : :read_issue
authorize_action_for!(board.parent, ability)
end end
def authorize_update_issue def authorize_update_issue
...@@ -31,6 +67,10 @@ module BoardsResponses ...@@ -31,6 +67,10 @@ module BoardsResponses
respond_with(@board) # rubocop:disable Gitlab/ModuleWithInstanceVariables respond_with(@board) # rubocop:disable Gitlab/ModuleWithInstanceVariables
end end
def serialize_as_json(resource)
resource.as_json(only: [:id])
end
def respond_with(resource) def respond_with(resource)
respond_to do |format| respond_to do |format|
format.html format.html
......
class Groups::BoardsController < Groups::ApplicationController class Groups::BoardsController < Groups::ApplicationController
prepend EE::Boards::BoardsController prepend EE::Boards::BoardsController
prepend EE::BoardsResponses
include BoardsResponses include BoardsResponses
before_action :check_group_issue_boards_available!
before_action :assign_endpoint_vars before_action :assign_endpoint_vars
def index def index
...@@ -23,4 +21,8 @@ class Groups::BoardsController < Groups::ApplicationController ...@@ -23,4 +21,8 @@ class Groups::BoardsController < Groups::ApplicationController
@namespace_path = group.to_param @namespace_path = group.to_param
@labels_endpoint = group_labels_url(group) @labels_endpoint = group_labels_url(group)
end end
def serialize_as_json(resource)
resource.as_json(only: [:id])
end
end end
class Projects::BoardsController < Projects::ApplicationController class Projects::BoardsController < Projects::ApplicationController
prepend EE::Boards::BoardsController prepend EE::Boards::BoardsController
prepend EE::BoardsResponses
include BoardsResponses include BoardsResponses
include IssuableCollections include IssuableCollections
......
...@@ -19,23 +19,35 @@ module BoardsHelper ...@@ -19,23 +19,35 @@ module BoardsHelper
end end
def build_issue_link_base def build_issue_link_base
project_issues_path(@project) if board.group_board?
"#{group_path(@board.group)}/:project_path/issues"
else
project_issues_path(@project)
end
end end
def board_base_url def board_base_url
project_boards_path(@project) if board.group_board?
group_boards_url(@group)
else
project_boards_path(@project)
end
end end
def multiple_boards_available? def multiple_boards_available?
current_board_parent.multiple_issue_boards_available?(current_user) current_board_parent.multiple_issue_boards_available?
end end
def current_board_path(board) def current_board_path(board)
@current_board_path ||= project_board_path(current_board_parent, board) @current_board_path ||= if board.group_board?
group_board_path(current_board_parent, board)
else
project_board_path(current_board_parent, board)
end
end end
def current_board_parent def current_board_parent
@current_board_parent ||= @project @current_board_parent ||= @group || @project
end end
def can_admin_issue? def can_admin_issue?
...@@ -49,7 +61,8 @@ module BoardsHelper ...@@ -49,7 +61,8 @@ module BoardsHelper
labels: labels_filter_path(true), labels: labels_filter_path(true),
labels_endpoint: @labels_endpoint, labels_endpoint: @labels_endpoint,
namespace_path: @namespace_path, namespace_path: @namespace_path,
project_path: @project&.try(:path) project_path: @project&.path,
group_path: @group&.path
} }
end end
...@@ -61,7 +74,8 @@ module BoardsHelper ...@@ -61,7 +74,8 @@ module BoardsHelper
field_name: 'issue[assignee_ids][]', field_name: 'issue[assignee_ids][]',
first_user: current_user&.username, first_user: current_user&.username,
current_user: 'true', current_user: 'true',
project_id: @project&.try(:id), project_id: @project&.id,
group_id: @group&.id,
null_user: 'true', null_user: 'true',
multi_select: 'true', multi_select: 'true',
'dropdown-header': dropdown_options[:data][:'dropdown-header'], 'dropdown-header': dropdown_options[:data][:'dropdown-header'],
......
...@@ -30,7 +30,7 @@ module FormHelper ...@@ -30,7 +30,7 @@ module FormHelper
null_user: true, null_user: true,
current_user: true, current_user: true,
project_id: @project&.id, project_id: @project&.id,
field_name: "issue[assignee_ids][]", field_name: 'issue[assignee_ids][]',
default_label: 'Unassigned', default_label: 'Unassigned',
'max-select': 1, 'max-select': 1,
'dropdown-header': 'Assignee', 'dropdown-header': 'Assignee',
......
...@@ -137,7 +137,7 @@ module GroupsHelper ...@@ -137,7 +137,7 @@ module GroupsHelper
links = [:overview, :group_members] links = [:overview, :group_members]
if can?(current_user, :read_cross_project) if can?(current_user, :read_cross_project)
links += [:activity, :issues, :labels, :milestones, :merge_requests] links += [:activity, :issues, :boards, :labels, :milestones, :merge_requests]
end end
if can?(current_user, :admin_group, @group) if can?(current_user, :admin_group, @group)
......
...@@ -38,5 +38,13 @@ module Emails ...@@ -38,5 +38,13 @@ module Emails
reply_to: @message.reply_to, reply_to: @message.reply_to,
subject: @message.subject) subject: @message.subject)
end end
def mirror_was_hard_failed_email(project_id, user_id)
@project = Project.find(project_id)
user = User.find(user_id)
mail(to: user.notification_email,
subject: subject('Repository mirroring paused'))
end
end end
end end
class Board < ActiveRecord::Base class Board < ActiveRecord::Base
prepend EE::Board prepend EE::Board
belongs_to :group
belongs_to :project belongs_to :project
has_many :lists, -> { order(:list_type, :position) }, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent has_many :lists, -> { order(:list_type, :position) }, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
validates :project, presence: true, if: :project_needed? validates :project, presence: true, if: :project_needed?
validates :group, presence: true, unless: :project
def project_needed? def project_needed?
true !group
end end
def parent def parent
project @parent ||= group || project
end end
def group_board? def group_board?
false group_id.present?
end end
def backlog_list def backlog_list
......
...@@ -36,6 +36,8 @@ class Group < Namespace ...@@ -36,6 +36,8 @@ class Group < Namespace
has_many :hooks, dependent: :destroy, class_name: 'GroupHook' # rubocop:disable Cop/ActiveRecordDependent has_many :hooks, dependent: :destroy, class_name: 'GroupHook' # rubocop:disable Cop/ActiveRecordDependent
has_many :uploads, as: :model, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :uploads, as: :model, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :boards
# We cannot simply set `has_many :audit_events, as: :entity, dependent: :destroy` # We cannot simply set `has_many :audit_events, as: :entity, dependent: :destroy`
# here since Group inherits from Namespace, the entity_type would be set to `Namespace`. # here since Group inherits from Namespace, the entity_type would be set to `Namespace`.
has_many :audit_events, -> { where(entity_type: Group) }, foreign_key: 'entity_id' has_many :audit_events, -> { where(entity_type: Group) }, foreign_key: 'entity_id'
......
class Label < ActiveRecord::Base class Label < ActiveRecord::Base
# EE specific
prepend EE::Label
include CacheMarkdownField include CacheMarkdownField
include Referable include Referable
include Subscribable include Subscribable
...@@ -38,6 +35,7 @@ class Label < ActiveRecord::Base ...@@ -38,6 +35,7 @@ class Label < ActiveRecord::Base
scope :templates, -> { where(template: true) } scope :templates, -> { where(template: true) }
scope :with_title, ->(title) { where(title: title) } scope :with_title, ->(title) { where(title: title) }
scope :with_lists_and_board, -> { joins(lists: :board).merge(List.movable) } scope :with_lists_and_board, -> { joins(lists: :board).merge(List.movable) }
scope :on_group_boards, ->(group_id) { with_lists_and_board.where(boards: { group_id: group_id }) }
scope :on_project_boards, ->(project_id) { with_lists_and_board.where(boards: { project_id: project_id }) } scope :on_project_boards, ->(project_id) { with_lists_and_board.where(boards: { project_id: project_id }) }
def self.prioritized(project) def self.prioritized(project)
......
...@@ -232,9 +232,9 @@ class Namespace < ActiveRecord::Base ...@@ -232,9 +232,9 @@ class Namespace < ActiveRecord::Base
has_parent? has_parent?
end end
## EE only # Overridden on EE module
def multiple_issue_boards_available?(user = nil) def multiple_issue_boards_available?
feature_available?(:multiple_issue_boards) false
end end
def full_path_was def full_path_was
......
...@@ -1693,8 +1693,9 @@ class Project < ActiveRecord::Base ...@@ -1693,8 +1693,9 @@ class Project < ActiveRecord::Base
end end
end end
def multiple_issue_boards_available?(user) # Overridden on EE module
feature_available?(:multiple_issue_boards, user) def multiple_issue_boards_available?
false
end end
def full_path_was def full_path_was
......
...@@ -51,7 +51,12 @@ class GroupPolicy < BasePolicy ...@@ -51,7 +51,12 @@ class GroupPolicy < BasePolicy
rule { has_access }.enable :read_namespace rule { has_access }.enable :read_namespace
rule { developer }.enable :admin_milestones rule { developer }.enable :admin_milestones
rule { reporter }.enable :admin_label
rule { reporter }.policy do
enable :admin_label
enable :admin_list
enable :admin_issue
end
rule { master }.policy do rule { master }.policy do
enable :create_projects enable :create_projects
......
...@@ -41,7 +41,11 @@ module Boards ...@@ -41,7 +41,11 @@ module Boards
end end
def set_parent def set_parent
params[:project_id] = parent.id if parent.is_a?(Group)
params[:group_id] = parent.id
else
params[:project_id] = parent.id
end
end end
def set_state def set_state
......
module Boards module Boards
module Issues module Issues
class MoveService < Boards::BaseService class MoveService < Boards::BaseService
prepend EE::Boards::Issues::MoveService
def execute(issue) def execute(issue)
return false unless can?(current_user, :update_issue, issue) return false unless can?(current_user, :update_issue, issue)
return false if issue_params.empty? return false if issue_params.empty?
...@@ -62,8 +60,10 @@ module Boards ...@@ -62,8 +60,10 @@ module Boards
label_ids = label_ids =
if moving_to_list.movable? if moving_to_list.movable?
moving_from_list.label_id moving_from_list.label_id
elsif board.group_board?
::Label.on_group_boards(parent.id).pluck(:label_id)
else else
Label.on_project_boards(parent.id).pluck(:label_id) ::Label.on_project_boards(parent.id).pluck(:label_id)
end end
Array(label_ids).compact Array(label_ids).compact
......
module Boards module Boards
module Lists module Lists
class CreateService < Boards::BaseService class CreateService < Boards::BaseService
prepend EE::Boards::Lists::CreateService
def execute(board) def execute(board)
List.transaction do List.transaction do
label = available_labels_for(board).find(params[:label_id]) label = available_labels_for(board).find(params[:label_id])
...@@ -14,7 +12,11 @@ module Boards ...@@ -14,7 +12,11 @@ module Boards
private private
def available_labels_for(board) def available_labels_for(board)
LabelsFinder.new(current_user, project_id: parent.id).execute if board.group_board?
parent.labels
else
LabelsFinder.new(current_user, project_id: parent.id).execute
end
end end
def next_position(board) def next_position(board)
......
module Ci
class CreateTraceArtifactService < BaseService
def execute(job)
return if job.job_artifacts_trace
job.trace.read do |stream|
break unless stream.file?
clone_file!(stream.path, JobArtifactUploader.workhorse_upload_path) do |clone_path|
create_job_trace!(job, clone_path)
FileUtils.rm(stream.path)
end
end
end
private
def create_job_trace!(job, path)
File.open(path) do |stream|
job.create_job_artifacts_trace!(
project: job.project,
file_type: :trace,
file: stream)
end
end
def clone_file!(src_path, temp_dir)
FileUtils.mkdir_p(temp_dir)
Dir.mktmpdir('tmp-trace', temp_dir) do |dir_path|
temp_path = File.join(dir_path, "job.log")
FileUtils.copy(src_path, temp_path)
yield(temp_path)
end
end
end
end
- page_title 'Labels' - page_title 'Labels'
- issuables = ['issues', 'merge requests'] + (@group&.feature_available?(:epics) ? ['epics'] : [])
.top-area.adjust .top-area.adjust
.nav-text .nav-text
Labels can be applied to issues and merge requests. Group labels are available for any project within the group. = _("Labels can be applied to %{features}. Group labels are available for any project within the group.") % { features: issuables.to_sentence }
.nav-controls .nav-controls
- if can?(current_user, :admin_label, @group) - if can?(current_user, :admin_label, @group)
...@@ -16,4 +18,4 @@ ...@@ -16,4 +18,4 @@
= paginate @labels, theme: 'gitlab' = paginate @labels, theme: 'gitlab'
- else - else
.nothing-here-block .nothing-here-block
No labels created yet. = _("No labels created yet.")
- issues_count = group_issues_count(state: 'opened') - issues_count = group_issues_count(state: 'opened')
- merge_requests_count = group_merge_requests_count(state: 'opened') - merge_requests_count = group_merge_requests_count(state: 'opened')
- issues_sub_menu_items = ['groups#issues', 'labels#index', 'milestones#index'] - issues_sub_menu_items = ['groups#issues', 'labels#index', 'milestones#index', 'boards#index', 'boards#show']
- if @group.feature_available?(:group_issue_boards)
- issues_sub_menu_items.push('boards#index', 'boards#show')
.nav-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?) } .nav-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?) }
.nav-sidebar-inner-scroll .nav-sidebar-inner-scroll
...@@ -62,6 +59,7 @@ ...@@ -62,6 +59,7 @@
%strong.fly-out-top-item-name %strong.fly-out-top-item-name
#{ _('Issues') } #{ _('Issues') }
%span.badge.count.issue_counter.fly-out-badge= number_with_delimiter(issues_count) %span.badge.count.issue_counter.fly-out-badge= number_with_delimiter(issues_count)
%li.divider.fly-out-top-item %li.divider.fly-out-top-item
= nav_link(path: 'groups#issues', html_options: { class: 'home' }) do = nav_link(path: 'groups#issues', html_options: { class: 'home' }) do
= link_to issues_group_path(@group), title: 'List' do = link_to issues_group_path(@group), title: 'List' do
...@@ -70,9 +68,9 @@ ...@@ -70,9 +68,9 @@
- if group_sidebar_link?(:boards) - if group_sidebar_link?(:boards)
= nav_link(path: ['boards#index', 'boards#show']) do = nav_link(path: ['boards#index', 'boards#show']) do
= link_to group_boards_path(@group), title: 'Boards' do = link_to group_boards_path(@group), title: boards_link_text do
%span %span
Boards = boards_link_text
- if group_sidebar_link?(:labels) - if group_sidebar_link?(:labels)
= nav_link(path: 'labels#index') do = nav_link(path: 'labels#index') do
......
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
- status = label_subscription_status(label, @project).inquiry if current_user - status = label_subscription_status(label, @project).inquiry if current_user
- subject = local_assigns[:subject] - subject = local_assigns[:subject]
- toggle_subscription_path = toggle_subscription_label_path(label, @project) if current_user - toggle_subscription_path = toggle_subscription_label_path(label, @project) if current_user
- show_label_epics_link = @group&.feature_available?(:epics)
- show_label_merge_requests_link = show_label_issuables_link?(label, :merge_requests, project: @project) - show_label_merge_requests_link = show_label_issuables_link?(label, :merge_requests, project: @project)
- show_label_issues_link = show_label_issuables_link?(label, :issues, project: @project) - show_label_issues_link = show_label_issuables_link?(label, :issues, project: @project)
...@@ -14,6 +15,10 @@ ...@@ -14,6 +15,10 @@
= icon('caret-down') = icon('caret-down')
.dropdown-menu.dropdown-menu-align-right .dropdown-menu.dropdown-menu-align-right
%ul %ul
- if show_label_epics_link
%li
= link_to group_epics_path(@group, label_name:[label.name]) do
View epics
- if show_label_merge_requests_link - if show_label_merge_requests_link
%li %li
= link_to_label(label, subject: subject, type: :merge_request) do = link_to_label(label, subject: subject, type: :merge_request) do
......
- subject = local_assigns[:subject] - subject = local_assigns[:subject]
- show_label_epics_link = @group&.feature_available?(:epics)
- show_label_issues_link = show_label_issuables_link?(label, :issues, project: @project) - show_label_issues_link = show_label_issuables_link?(label, :issues, project: @project)
- show_label_merge_requests_link = show_label_issuables_link?(label, :merge_requests, project: @project) - show_label_merge_requests_link = show_label_issuables_link?(label, :merge_requests, project: @project)
...@@ -23,6 +24,9 @@ ...@@ -23,6 +24,9 @@
.description-text .description-text
= markdown_field(label, :description) = markdown_field(label, :description)
.hidden-xs.hidden-sm .hidden-xs.hidden-sm
- if show_label_epics_link
= link_to 'Epics', group_epics_path(@group, label_name:[label.name])
&middot;
- if show_label_issues_link - if show_label_issues_link
= link_to_label(label, subject: subject) { 'Issues' } = link_to_label(label, subject: subject) { 'Issues' }
- if show_label_merge_requests_link - if show_label_merge_requests_link
......
...@@ -43,9 +43,9 @@ ...@@ -43,9 +43,9 @@
- pipeline_cache:expire_pipeline_cache - pipeline_cache:expire_pipeline_cache
- pipeline_creation:create_pipeline - pipeline_creation:create_pipeline
- pipeline_creation:run_pipeline_schedule - pipeline_creation:run_pipeline_schedule
- pipeline_background:archive_trace
- pipeline_default:build_coverage - pipeline_default:build_coverage
- pipeline_default:build_trace_sections - pipeline_default:build_trace_sections
- pipeline_default:create_trace_artifact
- pipeline_default:pipeline_metrics - pipeline_default:pipeline_metrics
- pipeline_default:pipeline_notification - pipeline_default:pipeline_notification
- pipeline_default:update_head_pipeline_for_merge_request - pipeline_default:update_head_pipeline_for_merge_request
......
class ArchiveTraceWorker
include ApplicationWorker
include PipelineBackgroundQueue
def perform(job_id)
Ci::Build.find_by(id: job_id).try do |job|
job.trace.archive!
end
end
end
...@@ -14,7 +14,7 @@ class BuildFinishedWorker ...@@ -14,7 +14,7 @@ class BuildFinishedWorker
# We execute that async as this are two indepentent operations that can be executed after TraceSections and Coverage # We execute that async as this are two indepentent operations that can be executed after TraceSections and Coverage
BuildHooksWorker.perform_async(build.id) BuildHooksWorker.perform_async(build.id)
CreateTraceArtifactWorker.perform_async(build.id) ArchiveTraceWorker.perform_async(build.id)
end end
end end
end end
##
# Concern for setting Sidekiq settings for the low priority CI pipeline workers.
#
module PipelineBackgroundQueue
extend ActiveSupport::Concern
included do
queue_namespace :pipeline_background
end
end
class CreateTraceArtifactWorker
include ApplicationWorker
include PipelineQueue
def perform(job_id)
Ci::Build.preload(:project, :user).find_by(id: job_id).try do |job|
Ci::CreateTraceArtifactService.new(job.project, job.user).execute(job)
end
end
end
---
title: Add archive feature to trace
merge_request: 17314
author:
type: added
...@@ -80,7 +80,6 @@ constraints(GroupUrlConstrainer.new) do ...@@ -80,7 +80,6 @@ constraints(GroupUrlConstrainer.new) do
end end
resources :billings, only: [:index] resources :billings, only: [:index]
resources :boards, only: [:index, :show, :create, :update, :destroy]
resources :epics do resources :epics do
member do member do
get :realtime_changes get :realtime_changes
...@@ -89,6 +88,9 @@ constraints(GroupUrlConstrainer.new) do ...@@ -89,6 +88,9 @@ constraints(GroupUrlConstrainer.new) do
resources :epic_issues, only: [:index, :create, :destroy, :update], as: 'issues', path: 'issues' resources :epic_issues, only: [:index, :create, :destroy, :update], as: 'issues', path: 'issues'
end end
# On CE only index and show are needed
resources :boards, only: [:index, :show, :create, :update, :destroy]
legacy_ee_group_boards_redirect = redirect do |params, request| legacy_ee_group_boards_redirect = redirect do |params, request|
path = "/groups/#{params[:group_id]}/-/boards" path = "/groups/#{params[:group_id]}/-/boards"
path << "/#{params[:extra_params]}" if params[:extra_params].present? path << "/#{params[:extra_params]}" if params[:extra_params].present?
......
...@@ -422,6 +422,7 @@ constraints(ProjectUrlConstrainer.new) do ...@@ -422,6 +422,7 @@ constraints(ProjectUrlConstrainer.new) do
get 'noteable/:target_type/:target_id/notes' => 'notes#index', as: 'noteable_notes' get 'noteable/:target_type/:target_id/notes' => 'notes#index', as: 'noteable_notes'
# On CE only index and show are needed
resources :boards, only: [:index, :show, :create, :update, :destroy] resources :boards, only: [:index, :show, :create, :update, :destroy]
resources :todos, only: [:create] resources :todos, only: [:create]
......
...@@ -69,6 +69,7 @@ ...@@ -69,6 +69,7 @@
- [storage_migrator, 1] - [storage_migrator, 1]
- [pages_domain_verification, 1] - [pages_domain_verification, 1]
- [plugin, 1] - [plugin, 1]
- [pipeline_background, 1]
# EE-specific queues # EE-specific queues
- [ldap_group_sync, 2] - [ldap_group_sync, 2]
......
class MigrateCreateTraceArtifactSidekiqQueue < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
def up
sidekiq_queue_migrate 'pipeline_default:create_trace_artifact', to: 'pipeline_background:archive_trace'
end
def down
sidekiq_queue_migrate 'pipeline_background:archive_trace', to: 'pipeline_default:create_trace_artifact'
end
end
...@@ -11,7 +11,7 @@ ...@@ -11,7 +11,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 20180305144721) do ActiveRecord::Schema.define(version: 20180306074045) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
......
...@@ -88,7 +88,7 @@ Manage your [repositories](user/project/repository/index.md) from the UI (user i ...@@ -88,7 +88,7 @@ Manage your [repositories](user/project/repository/index.md) from the UI (user i
- [Discussions](user/discussions/index.md): Threads, comments, and resolvable discussions in issues, commits, and merge requests. - [Discussions](user/discussions/index.md): Threads, comments, and resolvable discussions in issues, commits, and merge requests.
- [Issues](user/project/issues/index.md) - [Issues](user/project/issues/index.md)
- [Project issue Board](user/project/issue_board.md) - [Project issue Board](user/project/issue_board.md)
- **(Premium)** [Group Issue Boards](user/project/issue_board.md#group-issue-boards) - [Group Issue Boards](user/project/issue_board.md#group-issue-boards)
- **(Starter/Premium)** [Related Issues](user/project/issues/related_issues.md): create a relationship between issues - **(Starter/Premium)** [Related Issues](user/project/issues/related_issues.md): create a relationship between issues
- [Issues and merge requests templates](user/project/description_templates.md): Create templates for submitting new issues and merge requests. - [Issues and merge requests templates](user/project/description_templates.md): Create templates for submitting new issues and merge requests.
- [Labels](user/project/labels.md): Categorize your issues or merge requests based on descriptive titles. - [Labels](user/project/labels.md): Categorize your issues or merge requests based on descriptive titles.
......
...@@ -31,7 +31,7 @@ following locations: ...@@ -31,7 +31,7 @@ following locations:
- [Group Members](members.md) - [Group Members](members.md)
- [Issues](issues.md) - [Issues](issues.md)
- [Issue Boards](boards.md) - [Issue Boards](boards.md)
- **(Premium)** [Group Issue Boards] (group_boards.md) - [Group Issue Boards](group_boards.md)
- [Jobs](jobs.md) - [Jobs](jobs.md)
- [Keys](keys.md) - [Keys](keys.md)
- [Labels](labels.md) - [Labels](labels.md)
......
...@@ -210,4 +210,3 @@ DELETE /groups/:id/-/epics/:epic_iid ...@@ -210,4 +210,3 @@ DELETE /groups/:id/-/epics/:epic_iid
```bash ```bash
curl --header DELETE "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/1/-/epics/5?title=New%20Title curl --header DELETE "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/1/-/epics/5?title=New%20Title
``` ```
...@@ -96,12 +96,24 @@ Example response: ...@@ -96,12 +96,24 @@ Example response:
} }
``` ```
## Delete a Geo node
Removes the Geo node.
```
DELETE /geo_nodes/:id
```
| Attribute | Type | Required | Description |
|-----------|---------|----------|-------------------------|
| `id` | integer | yes | The ID of the Geo node. |
## Repair a Geo node ## Repair a Geo node
To repair the OAuth authentication of a Geo node. To repair the OAuth authentication of a Geo node.
``` ```
PUT /geo_nodes/:id/repair POST /geo_nodes/:id/repair
``` ```
Example response: Example response:
...@@ -177,6 +189,10 @@ Example response: ...@@ -177,6 +189,10 @@ Example response:
GET /geo_nodes/:id/status GET /geo_nodes/:id/status
``` ```
| Attribute | Type | Required | Description |
| --------- | ------- | -------- | ----------- |
| `refresh` | boolean | no | Attempt to fetch the latest status from the Geo node directly, ignoring the cache |
```bash ```bash
curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/geo_nodes/2/status curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/geo_nodes/2/status
``` ```
......
...@@ -1756,4 +1756,5 @@ CI with various languages. ...@@ -1756,4 +1756,5 @@ CI with various languages.
[ce-7447]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/7447 [ce-7447]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/7447
[ce-3442]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/3442 [ce-3442]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/3442
[schedules]: ../../user/project/pipelines/schedules.md [schedules]: ../../user/project/pipelines/schedules.md
[ee]: https://about.gitlab.com/gitlab-ee/
[gitlab-versions]: https://about.gitlab.com/products/ [gitlab-versions]: https://about.gitlab.com/products/
...@@ -103,6 +103,99 @@ Notes: ...@@ -103,6 +103,99 @@ Notes:
- You can use [`git rerere`](https://git-scm.com/blog/2010/03/08/rerere.html) - You can use [`git rerere`](https://git-scm.com/blog/2010/03/08/rerere.html)
to avoid resolving the same conflicts multiple times. to avoid resolving the same conflicts multiple times.
### Cherry-picking from CE to EE
For avoiding merge conflicts, we use a method of creating equivalent branches
for CE and EE. If the `ee-compat-check` job fails, this process is required.
This method only requires that you have cloned both CE and EE into your computer.
If you don't have them yet, please go ahead and clone them:
- Clone CE repo: `git clone git@gitlab.com:gitlab-org/gitlab-ce.git`
- Clone EE repo: `git clone git@gitlab.com:gitlab-org/gitlab-ee.git`
And the only additional setup we need is to add CE as remote of EE and vice-versa:
- Open two terminal windows, one in CE, and another one in EE:
- In EE: `git remote add ce git@gitlab.com:gitlab-org/gitlab-ce.git`
- In CE: `git remote add ee git@gitlab.com:gitlab-org/gitlab-ee.git`
That's all setup we need, so that we can cherry-pick a commit from CE to EE, and
from EE to CE.
Now, every time you create an MR for CE and EE:
1. Open two terminal windows, one in CE, and another one in EE
1. In the CE terminal:
1. Create the CE branch, e.g., `branch-example`
1. Make your changes and push a commit (commit A)
1. Create the CE merge request in GitLab
1. In the EE terminal:
1. Create the EE-equivalent branch ending with `-ee`, e.g.,
`git checkout -b branch-example-ee`
1. Fetch the CE branch: `git fetch ce branch-example`
1. Cherry-pick the commit A: `git cherry-pick commit-A-SHA`
1. If Git prompts you to fix the conflicts, do a `git status`
to check which files contain conflicts, fix them, save the files
1. Add the changes with `git add .` but **DO NOT commit** them
1. Continue cherry-picking: `git cherry-pick --continue`
1. Push to EE: `git push origin branch-example-ee`
1. Create the EE-equivalent MR and link to the CE MR from the
description "Ports [CE-MR-LINK] to EE"
1. Once all the jobs are passing in both CE and EE, you've addressed the
feedback from your own team, and got them approved, the merge requests can be merged.
1. When both MRs are ready, the EE merge request will be merged first, and the
CE-equivalent will be merged next.
**Important notes:**
- The commit SHA can be easily found from the GitLab UI. From a merge request,
open the tab **Commits** and click the copy icon to copy the commit SHA.
- To cherry-pick a **commit range**, such as [A > B > C > D] use:
```shell
git cherry-pick "oldest-commit-SHA^..newest-commit-SHA"
```
For example, suppose the commit A is the oldest, and its SHA is `4f5e4018c09ed797fdf446b3752f82e46f5af502`,
and the commit D is the newest, and its SHA is `80e1c9e56783bd57bd7129828ec20b252ebc0538`.
The cherry-pick command will be:
```shell
git cherry-pick "4f5e4018c09ed797fdf446b3752f82e46f5af502^..80e1c9e56783bd57bd7129828ec20b252ebc0538"
```
- To cherry-pick a **merge commit**, use the flag `-m 1`. For example, suppose that the
merge commit SHA is `138f5e2f20289bb376caffa0303adb0cac859ce1`:
```shell
git cherry-pick -m 1 138f5e2f20289bb376caffa0303adb0cac859ce1
```
- To cherry-pick multiple commits, such as B and D in a range [A > B > C > D], use:
```shell
git cherry-pick commmit-B-SHA commit-D-SHA
```
For example, suppose commit B SHA = `4f5e4018c09ed797fdf446b3752f82e46f5af502`,
and the commit D SHA = `80e1c9e56783bd57bd7129828ec20b252ebc0538`.
The cherry-pick command will be:
```shell
git cherry-pick 4f5e4018c09ed797fdf446b3752f82e46f5af502 80e1c9e56783bd57bd7129828ec20b252ebc0538
```
This case is particularly useful when you have a merge commit in a sequence of
commits and you want to cherry-pick all but the merge commit.
- If you push more commits to the CE branch, you can safely repeat the procedure
to cherry-pick them to the EE-equivalent branch. You can do that as many times as
necessary, using the same CE and EE branches.
- If you submitted the merge request to the CE repo and the `ee-compat-check` job passed,
you are not required to submit the EE-equivalent MR, but it's still recommended. If the
job failed, you are required to submit the EE MR so that you can fix the conflicts in EE
before merging your changes into CE.
--- ---
[Return to Development documentation](README.md) [Return to Development documentation](README.md)
...@@ -19,7 +19,7 @@ The one responsible for writing the first piece of documentation is the develope ...@@ -19,7 +19,7 @@ The one responsible for writing the first piece of documentation is the develope
wrote the code. It's the job of the Product Manager to ensure all features are wrote the code. It's the job of the Product Manager to ensure all features are
shipped with its docs, whether is a small or big change. At the pace GitLab evolves, shipped with its docs, whether is a small or big change. At the pace GitLab evolves,
this is the only way to keep the docs up-to-date. If you have any questions about it, this is the only way to keep the docs up-to-date. If you have any questions about it,
please ask a Technical Writer. Otherwise, when your content is ready, assign one of ask a Technical Writer. Otherwise, when your content is ready, assign one of
them to review it for you. them to review it for you.
We use the [monthly release blog post](https://about.gitlab.com/handbook/marketing/blog/release-posts/#monthly-releases) as a changelog checklist to ensure everything We use the [monthly release blog post](https://about.gitlab.com/handbook/marketing/blog/release-posts/#monthly-releases) as a changelog checklist to ensure everything
...@@ -27,6 +27,8 @@ is documented. ...@@ -27,6 +27,8 @@ is documented.
Whenever you submit a merge request for the documentation, use the documentation MR description template. Whenever you submit a merge request for the documentation, use the documentation MR description template.
Please check the [documentation workflow](https://about.gitlab.com/handbook/product/technical-writing/workflow/) before getting started.
### Documentation directory structure ### Documentation directory structure
The documentation is structured based on the GitLab UI structure itself, The documentation is structured based on the GitLab UI structure itself,
...@@ -40,7 +42,7 @@ all docs should be linked. Every new document should be cross-linked to its rela ...@@ -40,7 +42,7 @@ all docs should be linked. Every new document should be cross-linked to its rela
The directories `/workflow/`, `/gitlab-basics/`, `/university/`, and `/articles/` have The directories `/workflow/`, `/gitlab-basics/`, `/university/`, and `/articles/` have
been deprecated and the majority their docs have been moved to their correct location been deprecated and the majority their docs have been moved to their correct location
in small iterations. Please don't create new docs in these folders. in small iterations. Don't create new docs in these folders.
To move a document from its location to another directory, read the section To move a document from its location to another directory, read the section
[changing document location](doc_styleguide.md#changing-document-location) of the doc style guide. [changing document location](doc_styleguide.md#changing-document-location) of the doc style guide.
...@@ -116,6 +118,49 @@ choices: ...@@ -116,6 +118,49 @@ choices:
If your branch name matches any of the above, it will run only the docs If your branch name matches any of the above, it will run only the docs
tests. If it doesn't, the whole test suite will run (including docs). tests. If it doesn't, the whole test suite will run (including docs).
### Merge requests for GitLab documentation
Before getting started, make sure you read the introductory section
"[contributing to docs](#contributing-to-docs)" above and the
[tech writing workflow](https://about.gitlab.com/handbook/product/technical-writing/workflow/)
for GitLab Team members.
- Use the current [merge request description template](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/.gitlab/merge_request_templates/Documentation.md)
- Use the correct [branch name](#branch-naming)
- Label the MR `Documentation`
- Assign the correct milestone (see note below)
NOTE: **Note:**
If the release version you want to add the documentation to has already been
frozen or released, use the label `Pick into X.Y` to get it merged into
the correct release. Avoid picking into a past release as much as you can, as
it increases the work of the release managers.
#### Cherry-picking from CE to EE
As we have the `master` branch of CE merged into EE once a day, it's common to
run into merge conflicts. To avoid them, we [test for merge conflicts against EE](#testing)
with the `ee-compat-check` job, and use the following method of creating equivalent
branches for CE and EE.
Follow this [method for cherry-picking from CE to EE](automatic_ce_ee_merge.md#cherry-picking-from-ce-to-ee), with a few adjustments:
- Create the [CE branch](#branch-naming) starting with `docs-`,
e.g.: `git checkout -b docs-example`
- Create the EE-equivalent branch ending with `-ee`, e.g.,
`git checkout -b docs-example-ee`
- Once all the jobs are passing in CE and EE, and you've addressed the
feedback from your own team, assign the CE MR to a technical writer for review
- When both MRs are ready, the EE merge request will be merged first, and the
CE-equivalent will be merged next.
- Note that the review will occur only in the CE MR, as the EE MR
contains the same commits as the CE MR.
- If you have a few more changes that apply to the EE-version only, you can submit
a couple more commits to the EE branch, but ask the reviewer to review the EE merge request
additionally to the CE MR. If there are many EE-only changes though, start a new MR
to EE only.
### Previewing the changes live ### Previewing the changes live
If you want to preview the doc changes of your merge request live, you can use If you want to preview the doc changes of your merge request live, you can use
......
...@@ -272,13 +272,14 @@ to another list the label changes and a system not is recorded. ...@@ -272,13 +272,14 @@ to another list the label changes and a system not is recorded.
> Introduced in [GitLab Enterprise Edition 8.13](https://about.gitlab.com/2016/10/22/gitlab-8-13-released/#multiple-issue-boards-ee). > Introduced in [GitLab Enterprise Edition 8.13](https://about.gitlab.com/2016/10/22/gitlab-8-13-released/#multiple-issue-boards-ee).
Multiple Issue Boards, as the name suggests, allow for more than one Issue Board Multiple Issue Boards, as the name suggests, allow for more than one Issue Board
for a given project. This is great for large projects with more than one team for a given project or group. This is great for large projects with more than one team
or in situations where a repository is used to host the code of multiple or in situations where a repository is used to host the code of multiple
products. products.
Clicking on the current board name in the upper left corner will reveal a Clicking on the current board name in the upper left corner will reveal a
menu from where you can create another Issue Board and rename or delete the menu from where you can create another Issue Board and rename or delete the
existing one. existing one.
Multiple issue boards feature is available for **projects in GitLab Starter Edition** and for **groups in GitLab Premium Edition**.
![Multiple Issue Boards](img/issue_boards_multiple.png) ![Multiple Issue Boards](img/issue_boards_multiple.png)
......
...@@ -3,8 +3,9 @@ ...@@ -3,8 +3,9 @@
import issuableApp from '~/issue_show/components/app.vue'; import issuableApp from '~/issue_show/components/app.vue';
import relatedIssuesRoot from 'ee/related_issues/components/related_issues_root.vue'; import relatedIssuesRoot from 'ee/related_issues/components/related_issues_root.vue';
import issuableAppEventHub from '~/issue_show/event_hub'; import issuableAppEventHub from '~/issue_show/event_hub';
import epicHeader from './epic_header.vue';
import epicSidebar from '../../sidebar/components/sidebar_app.vue'; import epicSidebar from '../../sidebar/components/sidebar_app.vue';
import SidebarContext from '../sidebar_context';
import epicHeader from './epic_header.vue';
export default { export default {
name: 'EpicShowApp', name: 'EpicShowApp',
...@@ -85,6 +86,27 @@ ...@@ -85,6 +86,27 @@
type: String, type: String,
required: false, required: false,
}, },
labels: {
type: Array,
required: true,
},
namespace: {
type: String,
required: false,
default: '#',
},
labelsPath: {
type: String,
required: true,
},
labelsWebUrl: {
type: String,
required: true,
},
epicsWebUrl: {
type: String,
required: true,
},
}, },
data() { data() {
return { return {
...@@ -94,6 +116,9 @@ ...@@ -94,6 +116,9 @@
projectNamespace: '', projectNamespace: '',
}; };
}, },
mounted() {
this.sidebarContext = new SidebarContext();
},
methods: { methods: {
deleteEpic() { deleteEpic() {
issuableAppEventHub.$emit('delete.issuable'); issuableAppEventHub.$emit('delete.issuable');
...@@ -137,6 +162,12 @@ ...@@ -137,6 +162,12 @@
:editable="canUpdate" :editable="canUpdate"
:initial-start-date="startDate" :initial-start-date="startDate"
:initial-end-date="endDate" :initial-end-date="endDate"
:initial-labels="labels"
:namespace="namespace"
:update-path="updateEndpoint"
:labels-path="labelsPath"
:labels-web-url="labelsWebUrl"
:epics-web-url="epicsWebUrl"
/> />
<related-issues-root <related-issues-root
:endpoint="issueLinksEndpoint" :endpoint="issueLinksEndpoint"
......
import Vue from 'vue'; import Vue from 'vue';
import '~/vue_shared/models/label';
import EpicShowApp from './components/epic_show_app.vue'; import EpicShowApp from './components/epic_show_app.vue';
export default () => { export default () => {
...@@ -6,7 +7,7 @@ export default () => { ...@@ -6,7 +7,7 @@ export default () => {
const metaData = JSON.parse(el.dataset.meta); const metaData = JSON.parse(el.dataset.meta);
const initialData = JSON.parse(el.dataset.initial); const initialData = JSON.parse(el.dataset.initial);
const props = Object.assign({}, initialData, metaData); const props = Object.assign({}, initialData, metaData, el.dataset);
// Convert backend casing to match frontend style guide // Convert backend casing to match frontend style guide
props.startDate = props.start_date; props.startDate = props.start_date;
......
import Mousetrap from 'mousetrap';
export default class SidebarContext {
constructor() {
const $issuableSidebar = $('.js-issuable-update');
Mousetrap.bind('l', () => SidebarContext.openSidebarDropdown($issuableSidebar.find('.js-labels-block')));
$issuableSidebar
.off('click', '.js-sidebar-dropdown-toggle')
.on('click', '.js-sidebar-dropdown-toggle', function onClickEdit(e) {
e.preventDefault();
const $block = $(this).parents('.js-labels-block');
const $selectbox = $block.find('.js-selectbox');
// We use `:visible` to detect element visibility
// since labels dropdown itself is handled by
// labels_select.js which internally uses
// $.hide() & $.show() to toggle elements
// which requires us to use `display: none;`
// in `labels_select/base.vue` as well.
// see: https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/4773#note_61844731
if ($selectbox.is(':visible')) {
$selectbox.hide();
$block.find('.js-value').show();
} else {
$selectbox.show();
$block.find('.js-value').hide();
}
if ($selectbox.is(':visible')) {
setTimeout(() => $block.find('.js-label-select').trigger('click'), 0);
}
});
}
static openSidebarDropdown($block) {
$block.find('.js-sidebar-dropdown-toggle').trigger('click');
}
}
<script> <script>
/* global ListLabel */
/* eslint-disable vue/require-default-prop */ /* eslint-disable vue/require-default-prop */
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
import Flash from '~/flash'; import Flash from '~/flash';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import sidebarDatePicker from '~/vue_shared/components/sidebar/date_picker.vue'; import SidebarDatePicker from '~/vue_shared/components/sidebar/date_picker.vue';
import sidebarCollapsedGroupedDatePicker from '~/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue'; import SidebarCollapsedGroupedDatePicker from '~/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue';
import SidebarLabelsSelect from '~/vue_shared/components/sidebar/labels_select/base.vue';
import SidebarService from '../services/sidebar_service'; import SidebarService from '../services/sidebar_service';
import Store from '../stores/sidebar_store'; import Store from '../stores/sidebar_store';
export default { export default {
name: 'EpicSidebar', name: 'EpicSidebar',
components: { components: {
sidebarDatePicker, SidebarDatePicker,
sidebarCollapsedGroupedDatePicker, SidebarCollapsedGroupedDatePicker,
SidebarLabelsSelect,
}, },
props: { props: {
endpoint: { endpoint: {
...@@ -32,6 +35,31 @@ ...@@ -32,6 +35,31 @@
type: String, type: String,
required: false, required: false,
}, },
initialLabels: {
type: Array,
required: true,
},
namespace: {
type: String,
required: false,
default: '#',
},
updatePath: {
type: String,
required: true,
},
labelsPath: {
type: String,
required: true,
},
labelsWebUrl: {
type: String,
required: true,
},
epicsWebUrl: {
type: String,
required: true,
},
}, },
data() { data() {
const store = new Store({ const store = new Store({
...@@ -46,13 +74,16 @@ ...@@ -46,13 +74,16 @@
savingStartDate: false, savingStartDate: false,
savingEndDate: false, savingEndDate: false,
service: new SidebarService(this.endpoint), service: new SidebarService(this.endpoint),
epicContext: {
labels: this.initialLabels,
},
}; };
}, },
methods: { methods: {
toggleSidebar() { toggleSidebar() {
this.collapsed = !this.collapsed; this.collapsed = !this.collapsed;
const contentContainer = this.$el.closest('.page-with-sidebar'); const contentContainer = this.$el.closest('.page-with-contextual-sidebar');
contentContainer.classList.toggle('right-sidebar-expanded'); contentContainer.classList.toggle('right-sidebar-expanded');
contentContainer.classList.toggle('right-sidebar-collapsed'); contentContainer.classList.toggle('right-sidebar-collapsed');
...@@ -82,6 +113,24 @@ ...@@ -82,6 +113,24 @@
saveEndDate(date) { saveEndDate(date) {
return this.saveDate('end', date); return this.saveDate('end', date);
}, },
handleLabelClick(label) {
if (label.isAny) {
this.epicContext.labels = [];
} else {
const labelIndex = this.epicContext.labels.findIndex(l => l.id === label.id);
if (labelIndex === -1) {
this.epicContext.labels.push(new ListLabel({
id: label.id,
title: label.title,
color: label.color[0],
textColor: label.text_color,
}));
} else {
this.epicContext.labels.splice(labelIndex, 1);
}
}
},
}, },
}; };
</script> </script>
...@@ -91,9 +140,10 @@ ...@@ -91,9 +140,10 @@
class="right-sidebar" class="right-sidebar"
:class="{ 'right-sidebar-expanded' : !collapsed, 'right-sidebar-collapsed': collapsed }" :class="{ 'right-sidebar-expanded' : !collapsed, 'right-sidebar-collapsed': collapsed }"
> >
<div class="issuable-sidebar"> <div class="issuable-sidebar js-issuable-update">
<sidebar-date-picker <sidebar-date-picker
v-if="!collapsed" v-if="!collapsed"
block-class="start-date"
:collapsed="collapsed" :collapsed="collapsed"
:is-loading="savingStartDate" :is-loading="savingStartDate"
:editable="editable" :editable="editable"
...@@ -106,6 +156,7 @@ ...@@ -106,6 +156,7 @@
/> />
<sidebar-date-picker <sidebar-date-picker
v-if="!collapsed" v-if="!collapsed"
block-class="end-date"
:collapsed="collapsed" :collapsed="collapsed"
:is-loading="savingEndDate" :is-loading="savingEndDate"
:editable="editable" :editable="editable"
...@@ -123,6 +174,20 @@ ...@@ -123,6 +174,20 @@
:show-toggle-sidebar="true" :show-toggle-sidebar="true"
@toggleCollapse="toggleSidebar" @toggleCollapse="toggleSidebar"
/> />
<sidebar-labels-select
ability-name="epic"
:context="epicContext"
:namespace="namespace"
:update-path="updatePath"
:labels-path="labelsPath"
:labels-web-url="labelsWebUrl"
:label-filter-base-path="epicsWebUrl"
:can-edit="editable"
:show-create="true"
@onLabelClick="handleLabelClick"
>
{{ __('None') }}
</sidebar-labels-select>
</div> </div>
</aside> </aside>
</template> </template>
...@@ -5,6 +5,13 @@ const tokenKeys = [{ ...@@ -5,6 +5,13 @@ const tokenKeys = [{
symbol: '@', symbol: '@',
icon: 'pencil', icon: 'pencil',
tag: '@author', tag: '@author',
}, {
key: 'label',
type: 'array',
param: 'name[]',
symbol: '~',
icon: 'tag',
tag: '~label',
}]; }];
const alternativeTokenKeys = [{ const alternativeTokenKeys = [{
......
<script> <script>
import { s__ } from '~/locale';
import Flash from '~/flash';
import statusCodes from '~/lib/utils/http_status';
import loadingIcon from '~/vue_shared/components/loading_icon.vue'; import loadingIcon from '~/vue_shared/components/loading_icon.vue';
import modal from '~/vue_shared/components/modal.vue';
import SmartInterval from '~/smart_interval'; import SmartInterval from '~/smart_interval';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
import { NODE_ACTIONS } from '../constants';
import geoNodesList from './geo_nodes_list.vue'; import geoNodesList from './geo_nodes_list.vue';
export default { export default {
components: { components: {
loadingIcon, loadingIcon,
modal,
geoNodesList, geoNodesList,
}, },
props: { props: {
...@@ -33,6 +40,12 @@ ...@@ -33,6 +40,12 @@
return { return {
isLoading: true, isLoading: true,
hasError: false, hasError: false,
showModal: false,
targetNode: null,
targetNodeActionType: '',
modalKind: 'warning',
modalMessage: '',
modalActionLabel: '',
errorMessage: '', errorMessage: '',
}; };
}, },
...@@ -43,17 +56,34 @@ ...@@ -43,17 +56,34 @@
}, },
created() { created() {
eventHub.$on('pollNodeDetails', this.initNodeDetailsPolling); eventHub.$on('pollNodeDetails', this.initNodeDetailsPolling);
eventHub.$on('showNodeActionModal', this.showNodeActionModal);
eventHub.$on('repairNode', this.repairNode);
}, },
mounted() { mounted() {
this.fetchGeoNodes(); this.fetchGeoNodes();
}, },
beforeDestroy() { beforeDestroy() {
eventHub.$off('pollNodeDetails', this.initNodeDetailsPolling); eventHub.$off('pollNodeDetails', this.initNodeDetailsPolling);
eventHub.$off('showNodeActionModal', this.showNodeActionModal);
eventHub.$off('repairNode', this.repairNode);
if (this.nodePollingInterval) { if (this.nodePollingInterval) {
this.nodePollingInterval.stopTimer(); this.nodePollingInterval.stopTimer();
} }
}, },
methods: { methods: {
setNodeActionStatus(node, status) {
Object.assign(node, { nodeActionActive: status });
},
initNodeDetailsPolling(node) {
this.nodePollingInterval = new SmartInterval({
callback: this.fetchNodeDetails.bind(this, node),
startingInterval: 30000,
maxInterval: 120000,
hiddenInterval: 240000,
incrementByFactorOf: 15000,
immediateExecution: true,
});
},
fetchGeoNodes() { fetchGeoNodes() {
this.hasError = false; this.hasError = false;
this.service.getGeoNodes() this.service.getGeoNodes()
...@@ -67,8 +97,9 @@ ...@@ -67,8 +97,9 @@
this.errorMessage = err; this.errorMessage = err;
}); });
}, },
fetchNodeDetails(nodeId) { fetchNodeDetails(node) {
return this.service.getGeoNodeDetails(nodeId) const nodeId = node.id;
return this.service.getGeoNodeDetails(node)
.then(res => res.data) .then(res => res.data)
.then((nodeDetails) => { .then((nodeDetails) => {
const primaryNodeVersion = this.store.getPrimaryNodeVersion(); const primaryNodeVersion = this.store.getPrimaryNodeVersion();
...@@ -80,18 +111,81 @@ ...@@ -80,18 +111,81 @@
eventHub.$emit('nodeDetailsLoaded', this.store.getNodeDetails(nodeId)); eventHub.$emit('nodeDetailsLoaded', this.store.getNodeDetails(nodeId));
}) })
.catch((err) => { .catch((err) => {
eventHub.$emit('nodeDetailsLoadFailed', nodeId, err); if (err.response && err.response.status === statusCodes.NOT_FOUND) {
this.store.setNodeDetails(nodeId, {
geo_node_id: nodeId,
health: err.message,
health_status: 'Unknown',
missing_oauth_application: false,
sync_status_unavailable: true,
storage_shards_match: null,
});
eventHub.$emit('nodeDetailsLoaded', this.store.getNodeDetails(nodeId));
} else {
eventHub.$emit('nodeDetailsLoadFailed', nodeId, err);
}
}); });
}, },
initNodeDetailsPolling(nodeId) { repairNode(targetNode) {
this.nodePollingInterval = new SmartInterval({ this.setNodeActionStatus(targetNode, true);
callback: this.fetchNodeDetails.bind(this, nodeId), this.service.repairNode(targetNode)
startingInterval: 30000, .then(() => {
maxInterval: 120000, this.setNodeActionStatus(targetNode, false);
hiddenInterval: 240000, Flash(s__('GeoNodes|Node Authentication was successfully repaired.'), 'notice');
incrementByFactorOf: 15000, })
immediateExecution: true, .catch(() => {
}); this.setNodeActionStatus(targetNode, false);
Flash(s__('GeoNodes|Something went wrong while repairing node'));
});
},
toggleNode(targetNode) {
this.setNodeActionStatus(targetNode, true);
this.service.toggleNode(targetNode)
.then(res => res.data)
.then((node) => {
Object.assign(targetNode, { enabled: node.enabled, nodeActionActive: false });
})
.catch(() => {
this.setNodeActionStatus(targetNode, false);
Flash(s__('GeoNodes|Something went wrong while changing node status'));
});
},
removeNode(targetNode) {
this.setNodeActionStatus(targetNode, true);
this.service.removeNode(targetNode)
.then(() => {
this.store.removeNode(targetNode);
Flash(s__('GeoNodes|Node was successfully removed.'), 'notice');
})
.catch(() => {
this.setNodeActionStatus(targetNode, false);
Flash(s__('GeoNodes|Something went wrong while removing node'));
});
},
handleNodeAction() {
this.showModal = false;
if (this.targetNodeActionType === NODE_ACTIONS.TOGGLE) {
this.toggleNode(this.targetNode);
} else if (this.targetNodeActionType === NODE_ACTIONS.REMOVE) {
this.removeNode(this.targetNode);
}
},
showNodeActionModal({ actionType, node, modalKind = 'warning', modalMessage, modalActionLabel }) {
this.targetNode = node;
this.targetNodeActionType = actionType;
this.modalKind = modalKind;
this.modalMessage = modalMessage;
this.modalActionLabel = modalActionLabel;
if (actionType === NODE_ACTIONS.TOGGLE && !node.enabled) {
this.toggleNode(this.targetNode);
} else {
this.showModal = true;
}
},
hideNodeActionModal() {
this.showModal = false;
}, },
}, },
}; };
...@@ -120,5 +214,14 @@ ...@@ -120,5 +214,14 @@
> >
{{ errorMessage }} {{ errorMessage }}
</p> </p>
<modal
v-show="showModal"
:title="__('Are you sure?')"
:kind="modalKind"
:text="modalMessage"
:primary-button-label="modalActionLabel"
@cancel="hideNodeActionModal"
@submit="handleNodeAction"
/>
</div> </div>
</template> </template>
...@@ -2,7 +2,9 @@ ...@@ -2,7 +2,9 @@
import { __, s__ } from '~/locale'; import { __, s__ } from '~/locale';
import loadingIcon from '~/vue_shared/components/loading_icon.vue'; import loadingIcon from '~/vue_shared/components/loading_icon.vue';
import { NODE_ACTION_BASE_PATH, NODE_ACTIONS } from '../constants'; import eventHub from '../event_hub';
import { NODE_ACTIONS } from '../constants';
export default { export default {
components: { components: {
...@@ -22,11 +24,6 @@ ...@@ -22,11 +24,6 @@
required: true, required: true,
}, },
}, },
data() {
return {
isNodeToggleInProgress: false,
};
},
computed: { computed: {
isToggleAllowed() { isToggleAllowed() {
return !this.node.primary && this.nodeEditAllowed; return !this.node.primary && this.nodeEditAllowed;
...@@ -34,20 +31,27 @@ ...@@ -34,20 +31,27 @@
nodeToggleLabel() { nodeToggleLabel() {
return this.node.enabled ? __('Disable') : __('Enable'); return this.node.enabled ? __('Disable') : __('Enable');
}, },
nodeDisableMessage() { },
return this.node.enabled ? s__('GeoNodes|Disabling a node stops the sync process. Are you sure?') : ''; methods: {
}, onToggleNode() {
nodePath() { eventHub.$emit('showNodeActionModal', {
return `${NODE_ACTION_BASE_PATH}${this.node.id}`; actionType: NODE_ACTIONS.TOGGLE,
}, node: this.node,
nodeRepairAuthPath() { modalMessage: s__('GeoNodes|Disabling a node stops the sync process. Are you sure?'),
return `${this.nodePath}${NODE_ACTIONS.REPAIR}`; modalActionLabel: this.nodeToggleLabel,
});
}, },
nodeTogglePath() { onRemoveNode() {
return `${this.nodePath}${NODE_ACTIONS.TOGGLE}`; eventHub.$emit('showNodeActionModal', {
actionType: NODE_ACTIONS.REMOVE,
node: this.node,
modalKind: 'danger',
modalMessage: s__('GeoNodes|Removing a node stops the sync process. Are you sure?'),
modalActionLabel: __('Remove'),
});
}, },
nodeEditPath() { onRepairNode() {
return `${this.nodePath}${NODE_ACTIONS.EDIT}`; eventHub.$emit('repairNode', this.node);
}, },
}, },
}; };
...@@ -59,30 +63,29 @@ ...@@ -59,30 +63,29 @@
v-if="nodeMissingOauth" v-if="nodeMissingOauth"
class="node-action-container" class="node-action-container"
> >
<a <button
type="button"
class="btn btn-default btn-sm btn-node-action" class="btn btn-default btn-sm btn-node-action"
data-method="post" @click="onRepairNode"
:href="nodeRepairAuthPath"
> >
{{ s__('Repair authentication') }} {{ s__('Repair authentication') }}
</a> </button>
</div> </div>
<div <div
v-if="isToggleAllowed" v-if="isToggleAllowed"
class="node-action-container" class="node-action-container"
> >
<a <button
type="button"
class="btn btn-sm btn-node-action" class="btn btn-sm btn-node-action"
data-method="post"
:href="nodeTogglePath"
:data-confirm="nodeDisableMessage"
:class="{ :class="{
'btn-warning': node.enabled, 'btn-warning': node.enabled,
'btn-success': !node.enabled 'btn-success': !node.enabled
}" }"
@click="onToggleNode"
> >
{{ nodeToggleLabel }} {{ nodeToggleLabel }}
</a> </button>
</div> </div>
<div <div
v-if="nodeEditAllowed" v-if="nodeEditAllowed"
...@@ -90,19 +93,19 @@ ...@@ -90,19 +93,19 @@
> >
<a <a
class="btn btn-sm btn-node-action" class="btn btn-sm btn-node-action"
:href="nodeEditPath" :href="node.editPath"
> >
{{ __('Edit') }} {{ __('Edit') }}
</a> </a>
</div> </div>
<div class="node-action-container"> <div class="node-action-container">
<a <button
type="button"
class="btn btn-sm btn-node-action btn-danger" class="btn btn-sm btn-node-action btn-danger"
data-method="delete" @click="onRemoveNode"
:href="nodePath"
> >
{{ __('Remove') }} {{ __('Remove') }}
</a> </button>
</div> </div>
</div> </div>
</template> </template>
...@@ -106,6 +106,7 @@ ...@@ -106,6 +106,7 @@
/> />
<geo-node-sync-settings <geo-node-sync-settings
v-else-if="isCustomTypeSync" v-else-if="isCustomTypeSync"
:sync-status-unavailable="itemValue.syncStatusUnavailable"
:selective-sync-type="itemValue.selectiveSyncType" :selective-sync-type="itemValue.selectiveSyncType"
:last-event="itemValue.lastEvent" :last-event="itemValue.lastEvent"
:cursor-last-event="itemValue.cursorLastEvent" :cursor-last-event="itemValue.cursorLastEvent"
......
...@@ -97,6 +97,10 @@ ...@@ -97,6 +97,10 @@
return this.showAdvanceItems ? 'angle-up' : 'angle-down'; return this.showAdvanceItems ? 'angle-up' : 'angle-down';
}, },
nodeVersion() { nodeVersion() {
if (this.nodeDetails.version == null &&
this.nodeDetails.revision == null) {
return __('Unknown');
}
return `${this.nodeDetails.version} (${this.nodeDetails.revision})`; return `${this.nodeDetails.version} (${this.nodeDetails.revision})`;
}, },
replicationSlotWAL() { replicationSlotWAL() {
...@@ -113,7 +117,8 @@ ...@@ -113,7 +117,8 @@
return stringifyTime(parsedTime); return stringifyTime(parsedTime);
} }
return 'Unknown';
return __('Unknown');
}, },
lastEventStatus() { lastEventStatus() {
return { return {
...@@ -150,6 +155,7 @@ ...@@ -150,6 +155,7 @@
}, },
syncSettings() { syncSettings() {
return { return {
syncStatusUnavailable: this.nodeDetails.syncStatusUnavailable,
selectiveSyncType: this.nodeDetails.selectiveSyncType, selectiveSyncType: this.nodeDetails.selectiveSyncType,
lastEvent: this.nodeDetails.lastEvent, lastEvent: this.nodeDetails.lastEvent,
cursorLastEvent: this.nodeDetails.cursorLastEvent, cursorLastEvent: this.nodeDetails.cursorLastEvent,
......
...@@ -112,14 +112,16 @@ export default { ...@@ -112,14 +112,16 @@ export default {
} }
}, },
handleMounted() { handleMounted() {
eventHub.$emit('pollNodeDetails', this.node.id); eventHub.$emit('pollNodeDetails', this.node);
}, },
}, },
}; };
</script> </script>
<template> <template>
<li> <li
:class="{ 'node-action-active': node.nodeActionActive }"
>
<div class="row"> <div class="row">
<div class="col-md-8"> <div class="col-md-8">
<div class="row"> <div class="row">
...@@ -128,7 +130,7 @@ export default { ...@@ -128,7 +130,7 @@ export default {
{{ node.url }} {{ node.url }}
</strong> </strong>
<loading-icon <loading-icon
v-if="isNodeDetailsLoading" v-if="isNodeDetailsLoading || node.nodeActionActive"
class="node-details-loading prepend-left-10 pull-left inline" class="node-details-loading prepend-left-10 pull-left inline"
size="1" size="1"
/> />
......
...@@ -14,6 +14,11 @@ ...@@ -14,6 +14,11 @@
icon, icon,
}, },
props: { props: {
syncStatusUnavailable: {
type: Boolean,
required: false,
default: false,
},
selectiveSyncType: { selectiveSyncType: {
type: String, type: String,
required: false, required: false,
...@@ -105,6 +110,13 @@ ...@@ -105,6 +110,13 @@
class="node-detail-value" class="node-detail-value"
> >
<span <span
v-if="syncStatusUnavailable"
class="node-detail-value-bold"
>
{{ __('Unknown') }}
</span>
<span
v-else
v-tooltip v-tooltip
class="node-sync-settings inline" class="node-sync-settings inline"
data-placement="bottom" data-placement="bottom"
......
export const NODE_ACTION_BASE_PATH = '/admin/geo_nodes/';
export const NODE_ACTIONS = { export const NODE_ACTIONS = {
TOGGLE: '/toggle', TOGGLE: 'toggle',
EDIT: '/edit', REMOVE: 'remove',
REPAIR: '/repair',
}; };
export const VALUE_TYPE = { export const VALUE_TYPE = {
......
...@@ -14,11 +14,10 @@ export default () => { ...@@ -14,11 +14,10 @@ export default () => {
const el = document.getElementById('js-geo-nodes'); const el = document.getElementById('js-geo-nodes');
if (!el) { if (!el) {
return; return false;
} }
// eslint-disable-next-line no-new return new Vue({
new Vue({
el, el,
components: { components: {
geoNodesApp, geoNodesApp,
...@@ -28,7 +27,7 @@ export default () => { ...@@ -28,7 +27,7 @@ export default () => {
const nodeActionsAllowed = convertPermissionToBoolean(dataset.nodeActionsAllowed); const nodeActionsAllowed = convertPermissionToBoolean(dataset.nodeActionsAllowed);
const nodeEditAllowed = convertPermissionToBoolean(dataset.nodeEditAllowed); const nodeEditAllowed = convertPermissionToBoolean(dataset.nodeEditAllowed);
const store = new GeoNodesStore(dataset.primaryVersion, dataset.primaryRevision); const store = new GeoNodesStore(dataset.primaryVersion, dataset.primaryRevision);
const service = new GeoNodesService(dataset.nodeDetailsPath); const service = new GeoNodesService();
return { return {
store, store,
......
...@@ -3,8 +3,7 @@ import axios from '~/lib/utils/axios_utils'; ...@@ -3,8 +3,7 @@ import axios from '~/lib/utils/axios_utils';
import Api from '~/api'; import Api from '~/api';
export default class GeoNodesService { export default class GeoNodesService {
constructor(nodeDetailsBasePath) { constructor() {
this.geoNodeDetailsBasePath = nodeDetailsBasePath;
this.geoNodesPath = Api.buildUrl(Api.geoNodesPath); this.geoNodesPath = Api.buildUrl(Api.geoNodesPath);
} }
...@@ -12,8 +11,29 @@ export default class GeoNodesService { ...@@ -12,8 +11,29 @@ export default class GeoNodesService {
return axios.get(this.geoNodesPath); return axios.get(this.geoNodesPath);
} }
getGeoNodeDetails(nodeId) { // eslint-disable-next-line class-methods-use-this
const geoNodeDetailsPath = `${this.geoNodeDetailsBasePath}/${nodeId}/status.json`; getGeoNodeDetails(node) {
return axios.get(geoNodeDetailsPath); return axios.get(node.statusPath, {
params: {
refresh: true,
},
});
}
// eslint-disable-next-line class-methods-use-this
toggleNode(node) {
return axios.put(node.basePath, {
enabled: !node.enabled, // toggle from existing status
});
}
// eslint-disable-next-line class-methods-use-this
repairNode(node) {
return axios.post(node.repairPath);
}
// eslint-disable-next-line class-methods-use-this
removeNode(node) {
return axios.delete(node.basePath);
} }
} }
...@@ -8,7 +8,9 @@ export default class GeoNodesStore { ...@@ -8,7 +8,9 @@ export default class GeoNodesStore {
} }
setNodes(nodes) { setNodes(nodes) {
this.state.nodes = nodes; this.state.nodes = nodes.map(
node => GeoNodesStore.formatNode(node),
);
} }
getNodes() { getNodes() {
...@@ -19,6 +21,16 @@ export default class GeoNodesStore { ...@@ -19,6 +21,16 @@ export default class GeoNodesStore {
this.state.nodeDetails[nodeId] = GeoNodesStore.formatNodeDetails(nodeDetails); this.state.nodeDetails[nodeId] = GeoNodesStore.formatNodeDetails(nodeDetails);
} }
removeNode(node) {
const indexOfRemovedNode = this.state.nodes.indexOf(node);
if (indexOfRemovedNode > -1) {
this.state.nodes.splice(indexOfRemovedNode, 1);
if (this.state.nodeDetails[node.id]) {
delete this.state.nodeDetails[node.id];
}
}
}
getPrimaryNodeVersion() { getPrimaryNodeVersion() {
return { return {
version: this.state.primaryVersion, version: this.state.primaryVersion,
...@@ -30,6 +42,22 @@ export default class GeoNodesStore { ...@@ -30,6 +42,22 @@ export default class GeoNodesStore {
return this.state.nodeDetails[nodeId]; return this.state.nodeDetails[nodeId];
} }
static formatNode(rawNode) {
const { id, url, primary, current, enabled } = rawNode;
return {
id,
url,
primary,
current,
enabled,
nodeActionActive: false,
basePath: rawNode._links.self,
repairPath: rawNode._links.repair,
editPath: rawNode.web_edit_url,
statusPath: rawNode._links.status,
};
}
static formatNodeDetails(rawNodeDetails) { static formatNodeDetails(rawNodeDetails) {
return { return {
id: rawNodeDetails.geo_node_id, id: rawNodeDetails.geo_node_id,
...@@ -41,8 +69,9 @@ export default class GeoNodesStore { ...@@ -41,8 +69,9 @@ export default class GeoNodesStore {
primaryVersion: rawNodeDetails.primaryVersion, primaryVersion: rawNodeDetails.primaryVersion,
primaryRevision: rawNodeDetails.primaryRevision, primaryRevision: rawNodeDetails.primaryRevision,
replicationSlotWAL: rawNodeDetails.replication_slots_max_retained_wal_bytes, replicationSlotWAL: rawNodeDetails.replication_slots_max_retained_wal_bytes,
missingOAuthApplication: rawNodeDetails.missing_oauth_application, missingOAuthApplication: rawNodeDetails.missing_oauth_application || false,
storageShardsMatch: rawNodeDetails.storage_shards_match, storageShardsMatch: rawNodeDetails.storage_shards_match,
syncStatusUnavailable: rawNodeDetails.sync_status_unavailable || false,
replicationSlots: { replicationSlots: {
totalCount: rawNodeDetails.replication_slots_count || 0, totalCount: rawNodeDetails.replication_slots_count || 0,
successCount: rawNodeDetails.replication_slots_used_count || 0, successCount: rawNodeDetails.replication_slots_used_count || 0,
......
...@@ -83,12 +83,12 @@ export default { ...@@ -83,12 +83,12 @@ export default {
this.editor.attachModel(this.model); this.editor.attachModel(this.model);
this.model.onChange((model) => { this.model.onChange((model) => {
const { file } = this.model; const { file } = model;
if (file.active) { if (file.active) {
this.changeFileContent({ this.changeFileContent({
file, file,
content: model.getValue(), content: model.getModel().getValue(),
}); });
} }
}); });
......
...@@ -61,14 +61,14 @@ export default class Model { ...@@ -61,14 +61,14 @@ export default class Model {
this.events.set( this.events.set(
this.path, this.path,
this.disposable.add( this.disposable.add(
this.model.onDidChangeContent(e => cb(this.model, e)), this.model.onDidChangeContent(e => cb(this, e)),
), ),
); );
} }
updateContent(content) { updateContent(content) {
this.getModel().setValue(content);
this.getOriginalModel().setValue(content); this.getOriginalModel().setValue(content);
this.getModel().setValue(content);
} }
dispose() { dispose() {
......
...@@ -89,12 +89,17 @@ export const updateFilesAfterCommit = ( ...@@ -89,12 +89,17 @@ export const updateFilesAfterCommit = (
lastCommit, lastCommit,
}, { root: true }); }, { root: true });
eventHub.$emit(`editor.update.model.content.${entry.path}`, entry.content);
commit(rootTypes.SET_FILE_RAW_DATA, { commit(rootTypes.SET_FILE_RAW_DATA, {
file: entry, file: entry,
raw: entry.content, raw: entry.content,
}, { root: true }); }, { root: true });
eventHub.$emit(`editor.update.model.content.${entry.path}`, entry.raw); commit(rootTypes.TOGGLE_FILE_CHANGED, {
file: entry,
changed: false,
}, { root: true });
}); });
commit(rootTypes.REMOVE_ALL_CHANGES_FILES, null, { root: true }); commit(rootTypes.REMOVE_ALL_CHANGES_FILES, null, { root: true });
......
...@@ -39,6 +39,7 @@ export const DISCARD_FILE_CHANGES = 'DISCARD_FILE_CHANGES'; ...@@ -39,6 +39,7 @@ export const DISCARD_FILE_CHANGES = 'DISCARD_FILE_CHANGES';
export const CREATE_TMP_FILE = 'CREATE_TMP_FILE'; export const CREATE_TMP_FILE = 'CREATE_TMP_FILE';
export const ADD_FILE_TO_CHANGED = 'ADD_FILE_TO_CHANGED'; export const ADD_FILE_TO_CHANGED = 'ADD_FILE_TO_CHANGED';
export const REMOVE_FILE_FROM_CHANGED = 'REMOVE_FILE_FROM_CHANGED'; export const REMOVE_FILE_FROM_CHANGED = 'REMOVE_FILE_FROM_CHANGED';
export const TOGGLE_FILE_CHANGED = 'TOGGLE_FILE_CHANGED';
// Viewer mutation types // Viewer mutation types
export const SET_PREVIEW_MODE = 'SET_PREVIEW_MODE'; export const SET_PREVIEW_MODE = 'SET_PREVIEW_MODE';
......
...@@ -79,4 +79,9 @@ export default { ...@@ -79,4 +79,9 @@ export default {
state.changedFiles.splice(indexOfChangedFile, 1); state.changedFiles.splice(indexOfChangedFile, 1);
}, },
[types.TOGGLE_FILE_CHANGED](state, { file, changed }) {
Object.assign(file, {
changed,
});
},
}; };
...@@ -5,6 +5,9 @@ import initNewEpic from 'ee/epics/new_epic/new_epic_bundle'; ...@@ -5,6 +5,9 @@ import initNewEpic from 'ee/epics/new_epic/new_epic_bundle';
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
initFilteredSearch({ initFilteredSearch({
page: 'epics', page: 'epics',
isGroup: true,
isGroupAncestor: true,
isGroupDecendent: true,
filteredSearchTokenKeys: FilteredSearchTokenKeysEpics, filteredSearchTokenKeys: FilteredSearchTokenKeysEpics,
stateFiltersSelector: '.epics-state-filters', stateFiltersSelector: '.epics-state-filters',
}); });
......
...@@ -13,7 +13,7 @@ ...@@ -13,7 +13,7 @@
} }
.health-message { .health-message {
padding: 4px 8px 1px; padding: 2px 8px;
background-color: $red-100; background-color: $red-100;
color: $red-500; color: $red-500;
border-radius: $border-radius-default; border-radius: $border-radius-default;
...@@ -29,9 +29,9 @@ ...@@ -29,9 +29,9 @@
background: $white-light; background: $white-light;
} }
&.node-disabled, &.node-action-active {
&.node-disabled:hover { pointer-events: none;
background-color: $gray-lightest; opacity: 0.5;
} }
} }
} }
......
...@@ -37,50 +37,6 @@ class Admin::GeoNodesController < Admin::ApplicationController ...@@ -37,50 +37,6 @@ class Admin::GeoNodesController < Admin::ApplicationController
end end
end end
def destroy
@node.destroy
redirect_to admin_geo_nodes_path, status: 302, notice: 'Node was successfully removed.'
end
def repair
if !@node.missing_oauth_application?
flash[:notice] = "This node doesn't need to be repaired."
elsif @node.repair
flash[:notice] = 'Node Authentication was successfully repaired.'
else
flash[:alert] = 'There was a problem repairing Node Authentication.'
end
redirect_to admin_geo_nodes_path
end
def toggle
if @node.primary?
flash[:alert] = "Primary node can't be disabled."
else
if @node.toggle!(:enabled)
new_status = @node.enabled? ? 'enabled' : 'disabled'
flash[:notice] = "Node #{@node.url} was successfully #{new_status}."
else
action = @node.enabled? ? 'disabling' : 'enabling'
flash[:alert] = "There was a problem #{action} node #{@node.url}."
end
end
redirect_to admin_geo_nodes_path
end
def status
status = Geo::NodeStatusFetchService.new.call(@node)
respond_to do |format|
format.json do
render json: GeoNodeStatusSerializer.new.represent(status)
end
end
end
private private
def geo_node_params def geo_node_params
......
# Shared actions between Groups::BoardsController and Projects::BoardsController
module EE module EE
module Boards module Boards
module BoardsController module BoardsController
include ::Gitlab::Utils::StrongMemoize
extend ActiveSupport::Concern extend ActiveSupport::Concern
prepended do prepended do
before_action :check_multiple_issue_boards_available!, only: [:create] before_action :authorize_create_board!, only: [:create]
before_action :authorize_admin_board!, only: [:create, :update, :destroy] before_action :authorize_admin_board!, only: [:create, :update, :destroy]
before_action :find_board, only: [:update, :destroy]
end end
def create def create
...@@ -24,28 +25,26 @@ module EE ...@@ -24,28 +25,26 @@ module EE
end end
end end
# rubocop:disable Gitlab/ModuleWithInstanceVariables
def update def update
service = ::Boards::UpdateService.new(parent, current_user, board_params) service = ::Boards::UpdateService.new(parent, current_user, board_params)
service.execute(@board) service.execute(board)
respond_to do |format| respond_to do |format|
format.json do format.json do
if @board.valid? if board.valid?
extra_json = { board_path: board_path(@board) } extra_json = { board_path: board_path(board) }
render json: serialize_as_json(@board).merge(extra_json) render json: serialize_as_json(board).merge(extra_json)
else else
render json: @board.errors, status: :unprocessable_entity render json: board.errors, status: :unprocessable_entity
end end
end end
end end
end end
# rubocop:enable Gitlab/ModuleWithInstanceVariables
def destroy def destroy
service = ::Boards::DestroyService.new(parent, current_user) service = ::Boards::DestroyService.new(parent, current_user)
service.execute(@board) # rubocop:disable Gitlab/ModuleWithInstanceVariables service.execute(board)
respond_to do |format| respond_to do |format|
format.json { head :ok } format.json { head :ok }
...@@ -55,36 +54,22 @@ module EE ...@@ -55,36 +54,22 @@ module EE
private private
def authorize_admin_board! def board
return render_404 unless can?(current_user, :admin_board, parent) strong_memoize(:board) do
end parent.boards.find(params[:id])
end
def board_params
params.require(:board).permit(:name, :weight, :milestone_id, :assignee_id, label_ids: [])
end
def find_board
@board = parent.boards.find(params[:id]) # rubocop:disable Gitlab/ModuleWithInstanceVariables
end
def parent
@parent ||= @project || @group # rubocop:disable Gitlab/ModuleWithInstanceVariables
end end
def boards_path def authorize_create_board!
if @group # rubocop:disable Gitlab/ModuleWithInstanceVariables if group?
group_boards_path(parent) check_multiple_group_issue_boards_available!
else else
project_boards_path(parent) check_multiple_project_issue_boards_available!
end end
end end
def board_path(board) def authorize_admin_board!
if @group # rubocop:disable Gitlab/ModuleWithInstanceVariables return render_404 unless can?(current_user, :admin_board, parent)
group_board_path(parent, board)
else
project_board_path(parent, board)
end
end end
def serialize_as_json(resource) def serialize_as_json(resource)
......
module EE
module Boards
module IssuesController
extend ActiveSupport::Concern
include ControllerWithCrossProjectAccessCheck
prepended do
requires_cross_project_access if: -> { board.group_board? }
end
def issues_finder
return super unless board.group_board?
::IssuesFinder.new(current_user, group_id: board_parent.id)
end
def project
@project ||= begin
if board.group_board?
::Project.find(issue_params[:project_id])
else
super
end
end
end
end
end
end
module EE
module BoardsResponses
# Shared authorizations between projects and groups which
# have different policies on EE.
def authorize_read_list
ability = board.group_board? ? :read_group : :read_list
authorize_action_for!(board.parent, ability)
end
def authorize_read_issue
ability = board.group_board? ? :read_group : :read_issue
authorize_action_for!(board.parent, ability)
end
end
end
...@@ -17,7 +17,13 @@ class EpicsFinder < IssuableFinder ...@@ -17,7 +17,13 @@ class EpicsFinder < IssuableFinder
end end
def row_count def row_count
execute.count count = execute.count
# When filtering by multiple labels, count returns a hash of
# records grouped by id - so we just have to get length of the Hash.
# Once we have state for epics, we can use default issuables row_count
# method.
count.is_a?(Hash) ? count.length : count
end end
# we don't have states for epics for now this method (#4017) # we don't have states for epics for now this method (#4017)
......
...@@ -6,7 +6,7 @@ module EE ...@@ -6,7 +6,7 @@ module EE
def board_data def board_data
show_feature_promotion = (@project && show_promotions? && show_feature_promotion = (@project && show_promotions? &&
(!@project.feature_available?(:multiple_issue_boards) || (!@project.feature_available?(:multiple_project_issue_boards) ||
!@project.feature_available?(:scoped_issue_board) || !@project.feature_available?(:scoped_issue_board) ||
!@project.feature_available?(:issue_board_focus_mode))) !@project.feature_available?(:issue_board_focus_mode)))
...@@ -24,12 +24,6 @@ module EE ...@@ -24,12 +24,6 @@ module EE
super.merge(data) super.merge(data)
end end
def build_issue_link_base
return super unless @board.group_board?
"#{group_path(@board.group)}/:project_path/issues"
end
def current_board_json def current_board_json
board = @board || @boards.first board = @board || @boards.first
...@@ -43,42 +37,8 @@ module EE ...@@ -43,42 +37,8 @@ module EE
) )
end end
def board_base_url
if board.group_board?
group_boards_url(@group)
else
super
end
end
def current_board_path(board)
@current_board_path ||= begin
if board.group_board?
group_board_path(current_board_parent, board)
else
super(board)
end
end
end
def current_board_parent
@current_board_parent ||= @group || super
end
def can_admin_issue?
can?(current_user, :admin_issue, current_board_parent)
end
def board_list_data
super.merge(group_path: @group&.path)
end
def board_sidebar_user_data
super.merge(group_id: @group&.id)
end
def boards_link_text def boards_link_text
if @project.multiple_issue_boards_available?(current_user) if parent.multiple_issue_boards_available?
s_("IssueBoards|Boards") s_("IssueBoards|Boards")
else else
s_("IssueBoards|Board") s_("IssueBoards|Board")
......
...@@ -13,7 +13,6 @@ module EE ...@@ -13,7 +13,6 @@ module EE
{ {
primary_version: version.to_s, primary_version: version.to_s,
primary_revision: revision.to_s, primary_revision: revision.to_s,
node_details_path: admin_geo_nodes_path.to_s,
node_actions_allowed: ::Gitlab::Database.read_write?.to_s, node_actions_allowed: ::Gitlab::Database.read_write?.to_s,
node_edit_allowed: ::Gitlab::Geo.license_allows?.to_s node_edit_allowed: ::Gitlab::Geo.license_allows?.to_s
} }
......
module EpicsHelper module EpicsHelper
def epic_meta_data def epic_show_app_data(epic, opts)
author = @epic.author author = epic.author
group = epic.group
data = { epic_meta = {
created: @epic.created_at, created: epic.created_at,
author: { author: {
name: author.name, name: author.name,
url: user_path(author), url: user_path(author),
username: "@#{author.username}", username: "@#{author.username}",
src: avatar_icon_for_user(@epic.author) src: opts[:author_icon]
}, },
start_date: @epic.start_date, start_date: epic.start_date,
end_date: @epic.end_date end_date: epic.end_date
} }
data.to_json {
initial: opts[:initial].merge(labels: epic.labels).to_json,
meta: epic_meta.to_json,
namespace: group.path,
labels_path: group_labels_path(group, format: :json, only_group_labels: true, include_ancestor_groups: true),
labels_web_url: group_labels_path(group),
epics_web_url: group_epics_path(group)
}
end
def epic_endpoint_query_params(opts)
opts[:data] ||= {}
opts[:data][:endpoint_query_params] = {
only_group_labels: true,
include_ancestor_groups: true,
include_descendant_groups: true
}.to_json
opts
end end
end end
...@@ -6,7 +6,6 @@ module EE ...@@ -6,7 +6,6 @@ module EE
EMPTY_SCOPE_STATE = [nil, -1].freeze EMPTY_SCOPE_STATE = [nil, -1].freeze
prepended do prepended do
belongs_to :group
belongs_to :milestone belongs_to :milestone
has_many :board_labels has_many :board_labels
...@@ -20,19 +19,6 @@ module EE ...@@ -20,19 +19,6 @@ module EE
has_many :labels, through: :board_labels has_many :labels, through: :board_labels
validates :name, presence: true validates :name, presence: true
validates :group, presence: true, unless: :project
end
def project_needed?
!group
end
def parent
@parent ||= group || project
end
def group_board?
group_id.present?
end end
def milestone def milestone
......
...@@ -5,9 +5,9 @@ module EE ...@@ -5,9 +5,9 @@ module EE
# and be included in the `Group` model # and be included in the `Group` model
module Group module Group
extend ActiveSupport::Concern extend ActiveSupport::Concern
extend ::Gitlab::Utils::Override
included do included do
has_many :boards
has_many :epics has_many :epics
state_machine :ldap_sync_status, namespace: :ldap_sync, initial: :ready do state_machine :ldap_sync_status, namespace: :ldap_sync, initial: :ready do
...@@ -62,5 +62,10 @@ module EE ...@@ -62,5 +62,10 @@ module EE
def project_creation_level def project_creation_level
super || ::Gitlab::CurrentSettings.default_project_creation super || ::Gitlab::CurrentSettings.default_project_creation
end end
override :multiple_issue_boards_available?
def multiple_issue_boards_available?
feature_available?(:multiple_group_issue_boards)
end
end end
end end
module EE
module Label
extend ActiveSupport::Concern
prepended do
scope :on_group_boards, ->(group_id) { with_lists_and_board.where(boards: { group_id: group_id }) }
end
end
end
...@@ -223,6 +223,11 @@ module EE ...@@ -223,6 +223,11 @@ module EE
end end
end end
override :multiple_issue_boards_available?
def multiple_issue_boards_available?
feature_available?(:multiple_project_issue_boards)
end
def service_desk_enabled def service_desk_enabled
::EE::Gitlab::ServiceDesk.enabled?(project: self) && super ::EE::Gitlab::ServiceDesk.enabled?(project: self) && super
end end
......
...@@ -20,6 +20,12 @@ module EE ...@@ -20,6 +20,12 @@ module EE
end end
end end
after_transition started: :failed do |project, _|
if project.mirror? && project.mirror_hard_failed?
::NotificationService.new.mirror_was_hard_failed(project)
end
end
after_transition [:scheduled, :started] => [:finished, :failed] do |project, _| after_transition [:scheduled, :started] => [:finished, :failed] do |project, _|
::Gitlab::Mirror.decrement_capacity(project.id) if project.mirror? ::Gitlab::Mirror.decrement_capacity(project.id) if project.mirror?
end end
......
...@@ -24,7 +24,7 @@ class License < ActiveRecord::Base ...@@ -24,7 +24,7 @@ class License < ActiveRecord::Base
merge_request_squash merge_request_squash
multiple_ldap_servers multiple_ldap_servers
multiple_issue_assignees multiple_issue_assignees
multiple_issue_boards multiple_project_issue_boards
push_rules push_rules
protected_refs_for_users protected_refs_for_users
related_issues related_issues
...@@ -42,10 +42,10 @@ class License < ActiveRecord::Base ...@@ -42,10 +42,10 @@ class License < ActiveRecord::Base
extended_audit_events extended_audit_events
file_locks file_locks
geo geo
group_issue_boards
jira_dev_panel_integration jira_dev_panel_integration
ldap_group_sync_filter ldap_group_sync_filter
multiple_clusters multiple_clusters
multiple_group_issue_boards
merge_request_performance_metrics merge_request_performance_metrics
object_storage object_storage
service_desk service_desk
...@@ -87,7 +87,8 @@ class License < ActiveRecord::Base ...@@ -87,7 +87,8 @@ class License < ActiveRecord::Base
merge_request_approvers merge_request_approvers
merge_request_squash merge_request_squash
multiple_issue_assignees multiple_issue_assignees
multiple_issue_boards multiple_project_issue_boards
multiple_group_issue_boards
protected_refs_for_users protected_refs_for_users
push_rules push_rules
related_issues related_issues
......
...@@ -20,7 +20,6 @@ module EE ...@@ -20,7 +20,6 @@ module EE
rule { reporter }.policy do rule { reporter }.policy do
enable :admin_list enable :admin_list
enable :admin_board enable :admin_board
enable :admin_issue
end end
condition(:can_owners_manage_ldap, scope: :global) do condition(:can_owners_manage_ldap, scope: :global) do
......
...@@ -5,7 +5,7 @@ module EE ...@@ -5,7 +5,7 @@ module EE
override :can_create_board? override :can_create_board?
def can_create_board? def can_create_board?
parent.feature_available?(:multiple_issue_boards) || super parent.multiple_issue_boards_available? || super
end end
end end
end end
......
...@@ -2,14 +2,6 @@ module EE ...@@ -2,14 +2,6 @@ module EE
module Boards module Boards
module Issues module Issues
module ListService module ListService
def set_parent
if parent.is_a?(Group)
params[:group_id] = parent.id
else
super
end
end
def issues_label_links def issues_label_links
if has_valid_milestone? if has_valid_milestone?
super.where("issues.milestone_id = ?", board.milestone_id) super.where("issues.milestone_id = ?", board.milestone_id)
......
...@@ -5,7 +5,7 @@ module EE ...@@ -5,7 +5,7 @@ module EE
override :execute override :execute
def execute def execute
if parent.multiple_issue_boards_available?(current_user) if parent.multiple_issue_boards_available?
super super
else else
super.limit(1) super.limit(1)
......
module EE
module Boards
module Lists
module CreateService
def available_labels_for(board)
if board.group_board?
parent.labels
else
super
end
end
end
end
end
end
module EE
module Boards
module MoveService
def remove_label_ids
label_ids =
if moving_to_list.movable?
moving_from_list.label_id
elsif board.group_board?
::Label.on_group_boards(parent.id).pluck(:label_id)
else
::Label.on_project_boards(parent.id).pluck(:label_id)
end
Array(label_ids).compact
end
end
end
end
...@@ -20,7 +20,19 @@ module EE ...@@ -20,7 +20,19 @@ module EE
return if note.author == support_bot return if note.author == support_bot
return unless issue.subscribed?(support_bot, issue.project) return unless issue.subscribed?(support_bot, issue.project)
Notify.service_desk_new_note_email(issue.id, note.id).deliver_later mailer.service_desk_new_note_email(issue.id, note.id).deliver_later
end
def mirror_was_hard_failed(project)
recipients = project.members.owners_and_masters
unless recipients.present?
recipients = project.group.members.owners_and_masters
end
recipients.each do |recipient|
mailer.mirror_was_hard_failed_email(project.id, recipient.user.id).deliver_later
end
end end
end end
end end
...@@ -12,3 +12,7 @@ ...@@ -12,3 +12,7 @@
&middot; &middot;
opened #{time_ago_with_tooltip(epic.created_at, placement: 'bottom')} opened #{time_ago_with_tooltip(epic.created_at, placement: 'bottom')}
by #{link_to_member(@group, epic.author, avatar: false)} by #{link_to_member(@group, epic.author, avatar: false)}
- if epic.labels.any?
&nbsp;
- epic.labels.each do |label|
= link_to render_colored_label(label, tooltip: true), group_epics_path(@group, label_name:[label.name]), class: 'label-link'
...@@ -15,4 +15,4 @@ ...@@ -15,4 +15,4 @@
- content_for :page_specific_javascripts do - content_for :page_specific_javascripts do
= webpack_bundle_tag 'common_vue' = webpack_bundle_tag 'common_vue'
#epic-show-app{ data: { initial: issuable_initial_data(@epic).to_json, meta: epic_meta_data } } #epic-show-app{ data: epic_show_app_data(@epic, author_icon: avatar_icon_for_user(@epic.author), initial: issuable_initial_data(@epic)) }
%p
Repository mirroring on #{@project.full_path} has been paused due to too many failures. The last failure was:
%pre
= @project.import_error
%p
To resume mirroring update your #{link_to("repository mirroring settings", project_settings_repository_path(@project))}.
Repository mirroring on <%= @project.full_path %> has been paused due to too many failures. The last failure was:
<%= @project.import_error %>
To resume mirroring update your repository settings at <%= project_settings_repository_url(@project) %>.
...@@ -28,7 +28,7 @@ ...@@ -28,7 +28,7 @@
- if can?(current_user, :admin_board, parent) - if can?(current_user, :admin_board, parent)
.dropdown-footer .dropdown-footer
%ul.dropdown-footer-list %ul.dropdown-footer-list
- if parent.feature_available?(:multiple_issue_boards) - if parent.multiple_issue_boards_available?
%li %li
%a{ "href" => "#", "v-on:click.prevent" => "showPage('new')" } %a{ "href" => "#", "v-on:click.prevent" => "showPage('new')" }
Create new board Create new board
......
...@@ -19,7 +19,7 @@ ...@@ -19,7 +19,7 @@
.scroll-container .scroll-container
%ul.tokens-container.list-unstyled %ul.tokens-container.list-unstyled
%li.input-token %li.input-token
%input.form-control.filtered-search{ search_filter_input_options(type) } %input.form-control.filtered-search{ epic_endpoint_query_params(search_filter_input_options(type)) }
#js-dropdown-hint.filtered-search-input-dropdown-menu.dropdown-menu.hint-dropdown #js-dropdown-hint.filtered-search-input-dropdown-menu.dropdown-menu.hint-dropdown
%ul{ data: { dropdown: true } } %ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { action: 'submit' } } %li.filter-dropdown-item{ data: { action: 'submit' } }
...@@ -46,6 +46,18 @@ ...@@ -46,6 +46,18 @@
= render 'shared/issuable/user_dropdown_item', = render 'shared/issuable/user_dropdown_item',
user: User.new(username: '{{username}}', name: '{{name}}'), user: User.new(username: '{{username}}', name: '{{name}}'),
avatar: { lazy: true, url: '{{avatar_url}}' } avatar: { lazy: true, url: '{{avatar_url}}' }
#js-dropdown-label.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { value: 'none' } }
%button.btn.btn-link{ type: 'button' }
= _("No Label")
%li.divider.droplab-item-ignore
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item{ type: 'button' }
%button.btn.btn-link
%span.dropdown-label-box{ style: 'background: {{color}}' }
%span.label-title.js-data-value
{{ title }}
%button.clear-search.hidden{ type: 'button' } %button.clear-search.hidden{ type: 'button' }
= icon('times') = icon('times')
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment