Commit d11d03fe authored by Sean McGivern's avatar Sean McGivern

Merge branch 'keyval-labels-ee' into 'master'

Add mutually exclusive key-value labels

Closes #9175

See merge request gitlab-org/gitlab-ee!9983
parents 78498736 c613a8a1
...@@ -378,6 +378,8 @@ export default () => { ...@@ -378,6 +378,8 @@ export default () => {
groupId: Number(dataset.groupId), groupId: Number(dataset.groupId),
scopedIssueBoardFeatureEnabled: parseBoolean(dataset.scopedIssueBoardFeatureEnabled), scopedIssueBoardFeatureEnabled: parseBoolean(dataset.scopedIssueBoardFeatureEnabled),
weights: JSON.parse(dataset.weights), weights: JSON.parse(dataset.weights),
enableScopedLabels: $boardApp.dataset.scopedLabels,
keyValueDocumentationLink: $boardApp.dataset.keyValueDocumentationLink,
}; };
return { boardsSelectorProps }; return { boardsSelectorProps };
......
...@@ -132,7 +132,17 @@ class ListIssue { ...@@ -132,7 +132,17 @@ class ListIssue {
} }
const projectPath = this.project ? this.project.path : ''; const projectPath = this.project ? this.project.path : '';
return Vue.http.patch(`${this.path}.json`, data); return Vue.http.patch(`${this.path}.json`, data).then(({ body = {} } = {}) => {
/**
* Since post implementation of Scoped labels, server can reject
* same key-ed labels. To keep the UI and server Model consistent,
* we're just assigning labels that server echo's back to us when we
* PATCH the said object.
*/
if (body) {
this.labels = body.labels;
}
});
} }
} }
......
...@@ -11,6 +11,7 @@ import CreateLabelDropdown from './create_label'; ...@@ -11,6 +11,7 @@ import CreateLabelDropdown from './create_label';
import flash from './flash'; import flash from './flash';
import ModalStore from './boards/stores/modal_store'; import ModalStore from './boards/stores/modal_store';
import boardsStore from './boards/stores/boards_store'; import boardsStore from './boards/stores/boards_store';
import { isEE } from '~/lib/utils/common_utils';
export default class LabelsSelect { export default class LabelsSelect {
constructor(els, options = {}) { constructor(els, options = {}) {
...@@ -86,8 +87,9 @@ export default class LabelsSelect { ...@@ -86,8 +87,9 @@ export default class LabelsSelect {
return this.value; return this.value;
}) })
.get(); .get();
const scopedLabels = $dropdown.data('scopedLabels');
const scopedLabelsDocumentationLink = $dropdown.data('scopedLabelsDocumentationLink');
const { handleClick } = options; const { handleClick } = options;
$sidebarLabelTooltip.tooltip(); $sidebarLabelTooltip.tooltip();
if ($dropdown.closest('.dropdown').find('.dropdown-new-label').length) { if ($dropdown.closest('.dropdown').find('.dropdown-new-label').length) {
...@@ -132,8 +134,49 @@ export default class LabelsSelect { ...@@ -132,8 +134,49 @@ export default class LabelsSelect {
template = LabelsSelect.getLabelTemplate({ template = LabelsSelect.getLabelTemplate({
labels: data.labels, labels: data.labels,
issueUpdateURL, issueUpdateURL,
enableScopedLabels: scopedLabels,
scopedLabelsDocumentationLink,
}); });
labelCount = data.labels.length; labelCount = data.labels.length;
// EE Specific
if (isEE) {
/**
* For Scoped labels, the last label selected with the
* same key will be applied to the current issueable.
*
* If these are the labels - priority::1, priority::2; and if
* we apply them in the same order, only priority::2 will stick
* with the issuable.
*
* In the current dropdown implementation, we keep track of all
* the labels selected via a hidden DOM element. Since a User
* can select priority::1 and priority::2 at the same time, the
* DOM will have 2 hidden input and the dropdown will show both
* the items selected but in reality server only applied
* priority::2.
*
* We find all the labels then find all the labels server accepted
* and then remove the excess ones.
*/
const toRemoveIds = Array.from(
$form.find(`input[type="hidden"][name="${fieldName}"]`),
)
.map(el => el.value)
.map(Number);
data.labels.forEach(label => {
const index = toRemoveIds.indexOf(label.id);
toRemoveIds.splice(index, 1);
});
toRemoveIds.forEach(id => {
$form
.find(`input[type="hidden"][name="${fieldName}"][value="${id}"]`)
.last()
.remove();
});
}
} else { } else {
template = '<span class="no-value">None</span>'; template = '<span class="no-value">None</span>';
} }
...@@ -358,6 +401,7 @@ export default class LabelsSelect { ...@@ -358,6 +401,7 @@ export default class LabelsSelect {
} else { } else {
if (!$dropdown.hasClass('js-filter-bulk-update')) { if (!$dropdown.hasClass('js-filter-bulk-update')) {
saveLabelData(); saveLabelData();
$dropdown.data('glDropdown').clearMenu();
} }
} }
} }
...@@ -471,19 +515,62 @@ export default class LabelsSelect { ...@@ -471,19 +515,62 @@ export default class LabelsSelect {
// so best approach is to use traditional way of // so best approach is to use traditional way of
// concatenation // concatenation
// see: http://2ality.com/2016/05/template-literal-whitespace.html#joining-arrays // see: http://2ality.com/2016/05/template-literal-whitespace.html#joining-arrays
const tpl = _.template(
const labelTemplate = _.template(
[ [
'<% _.each(labels, function(label){ %>',
'<a href="<%- issueUpdateURL.slice(0, issueUpdateURL.lastIndexOf("/")) %>?label_name[]=<%- encodeURIComponent(label.title) %>">', '<a href="<%- issueUpdateURL.slice(0, issueUpdateURL.lastIndexOf("/")) %>?label_name[]=<%- encodeURIComponent(label.title) %>">',
'<span class="badge label has-tooltip color-label" title="<%- label.description %>" style="background-color: <%- label.color %>; color: <%- label.text_color %>;">', '<span class="badge label has-tooltip color-label" <%= linkAttrs %> title="<%= tooltipTitleTemplate({ label, isScopedLabel, enableScopedLabels, escapeStr }) %>" style="background-color: <%= escapeStr(label.color) %>; color: <%= escapeStr(label.text_color) %>;">',
'<%- label.title %>', '<%- label.title %>',
'</span>', '</span>',
'</a>', '</a>',
].join(''),
);
const infoIconTemplate = _.template(
[
'<a href="<%= scopedLabelsDocumentationLink %>" class="label scoped-label" target="_blank" rel="noopener">',
'<i class="fa fa-question-circle" style="background-color: <%= escapeStr(label.color) %>; color: <%= escapeStr(label.text_color) %>;"></i>',
'</a>',
].join(''),
);
const tooltipTitleTemplate = _.template(
[
'<% if (isScopedLabel(label) && enableScopedLabels) { %>',
"<span class='font-weight-bold scoped-label-tooltip-title'>Scoped label</span>",
'<br />',
'<%= escapeStr(label.description) %>',
'<% } else { %>',
'<%= escapeStr(label.description) %>',
'<% } %>',
].join(''),
);
const isScopedLabel = label => label.title.indexOf('::') !== -1;
const tpl = _.template(
[
'<% _.each(labels, function(label){ %>',
'<% if (isScopedLabel(label) && enableScopedLabels) { %>',
'<span class="d-inline-block position-relative scoped-label-wrapper">',
'<%= labelTemplate({ label, issueUpdateURL, isScopedLabel, enableScopedLabels, tooltipTitleTemplate, escapeStr, linkAttrs: \'data-html="true"\' }) %>',
'<%= infoIconTemplate({ label, scopedLabelsDocumentationLink, escapeStr }) %>',
'</span>',
'<% } else { %>',
'<%= labelTemplate({ label, issueUpdateURL, isScopedLabel, enableScopedLabels, tooltipTitleTemplate, escapeStr, linkAttrs: "" }) %>',
'<% } %>',
'<% }); %>', '<% }); %>',
].join(''), ].join(''),
); );
return tpl(tplData); return tpl({
...tplData,
labelTemplate,
infoIconTemplate,
tooltipTitleTemplate,
isScopedLabel,
escapeStr: _.escape,
});
} }
bindEvents() { bindEvents() {
......
import Labels from '~/labels'; import Labels from 'ee_else_ce/labels';
document.addEventListener('DOMContentLoaded', () => new Labels()); document.addEventListener('DOMContentLoaded', () => new Labels());
import Labels from '~/labels'; import Labels from 'ee_else_ce/labels';
document.addEventListener('DOMContentLoaded', () => new Labels()); document.addEventListener('DOMContentLoaded', () => new Labels());
import Labels from '~/labels'; import Labels from 'ee_else_ce/labels';
document.addEventListener('DOMContentLoaded', () => new Labels()); document.addEventListener('DOMContentLoaded', () => new Labels());
import Labels from '~/labels'; import Labels from 'ee_else_ce/labels';
document.addEventListener('DOMContentLoaded', () => new Labels()); document.addEventListener('DOMContentLoaded', () => new Labels());
...@@ -75,6 +75,16 @@ export default { ...@@ -75,6 +75,16 @@ export default {
required: false, required: false,
default: false, default: false,
}, },
enableScopedLabels: {
type: Boolean,
require: false,
default: false,
},
scopedLabelsDocumentationLink: {
type: String,
require: false,
default: '#',
},
}, },
computed: { computed: {
hiddenInputName() { hiddenInputName() {
...@@ -123,7 +133,12 @@ export default { ...@@ -123,7 +133,12 @@ export default {
@onValueClick="handleCollapsedValueClick" @onValueClick="handleCollapsedValueClick"
/> />
<dropdown-title :can-edit="canEdit" /> <dropdown-title :can-edit="canEdit" />
<dropdown-value :labels="context.labels" :label-filter-base-path="labelFilterBasePath"> <dropdown-value
:labels="context.labels"
:label-filter-base-path="labelFilterBasePath"
:scoped-labels-documentation-link="scopedLabelsDocumentationLink"
:enable-scoped-labels="enableScopedLabels"
>
<slot></slot> <slot></slot>
</dropdown-value> </dropdown-value>
<div v-if="canEdit" class="selectbox js-selectbox" style="display: none;"> <div v-if="canEdit" class="selectbox js-selectbox" style="display: none;">
...@@ -142,6 +157,8 @@ export default { ...@@ -142,6 +157,8 @@ export default {
:namespace="namespace" :namespace="namespace"
:labels="context.labels" :labels="context.labels"
:show-extra-options="!showCreate" :show-extra-options="!showCreate"
:scoped-labels-documentation-link="scopedLabelsDocumentationLink"
:enable-scoped-labels="enableScopedLabels"
/> />
<div <div
class="dropdown-menu dropdown-select dropdown-menu-paging class="dropdown-menu dropdown-select dropdown-menu-paging
......
...@@ -31,6 +31,16 @@ export default { ...@@ -31,6 +31,16 @@ export default {
type: Boolean, type: Boolean,
required: true, required: true,
}, },
enableScopedLabels: {
type: Boolean,
require: false,
default: false,
},
scopedLabelsDocumentationLink: {
type: String,
require: false,
default: '#',
},
}, },
computed: { computed: {
dropdownToggleText() { dropdownToggleText() {
...@@ -61,6 +71,8 @@ export default { ...@@ -61,6 +71,8 @@ export default {
:data-labels="labelsPath" :data-labels="labelsPath"
:data-namespace-path="namespace" :data-namespace-path="namespace"
:data-show-any="showExtraOptions" :data-show-any="showExtraOptions"
:data-scoped-labels="enableScopedLabels"
:data-scoped-labels-documentation-link="scopedLabelsDocumentationLink"
type="button" type="button"
class="dropdown-menu-toggle wide js-label-select js-multiselect js-context-config-modal" class="dropdown-menu-toggle wide js-label-select js-multiselect js-context-config-modal"
data-toggle="dropdown" data-toggle="dropdown"
......
<script> <script>
import tooltip from '~/vue_shared/directives/tooltip'; import DropdownValueScopedLabel from './dropdown_value_scoped_label.vue';
import DropdownValueRegularLabel from './dropdown_value_regular_label.vue';
export default { export default {
directives: { components: {
tooltip, DropdownValueScopedLabel,
DropdownValueRegularLabel,
}, },
props: { props: {
labels: { labels: {
...@@ -14,6 +16,16 @@ export default { ...@@ -14,6 +16,16 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
enableScopedLabels: {
type: Boolean,
required: false,
default: false,
},
scopedLabelsDocumentationLink: {
type: String,
required: false,
default: '#',
},
}, },
computed: { computed: {
isEmpty() { isEmpty() {
...@@ -30,6 +42,12 @@ export default { ...@@ -30,6 +42,12 @@ export default {
backgroundColor: label.color, backgroundColor: label.color,
}; };
}, },
scopedLabelsDescription({ description = '' }) {
return `<span class="font-weight-bold scoped-label-tooltip-title">Scoped label</span><br />${description}`;
},
showScopedLabels({ title = '' }) {
return this.enableScopedLabels && title.indexOf('::') !== -1;
},
}, },
}; };
</script> </script>
...@@ -44,17 +62,24 @@ export default { ...@@ -44,17 +62,24 @@ export default {
<span v-if="isEmpty" class="text-secondary"> <span v-if="isEmpty" class="text-secondary">
<slot>{{ __('None') }}</slot> <slot>{{ __('None') }}</slot>
</span> </span>
<a v-for="label in labels" v-else :key="label.id" :href="labelFilterUrl(label)">
<span <template v-for="label in labels" v-else>
v-tooltip <dropdown-value-scoped-label
:style="labelStyle(label)" v-if="showScopedLabels(label)"
:title="label.description" :key="label.id"
class="badge color-label" :label="label"
data-placement="bottom" :label-filter-url="labelFilterUrl(label)"
data-container="body" :label-style="labelStyle(label)"
> :scoped-labels-documentation-link="scopedLabelsDocumentationLink"
{{ label.title }} />
</span>
</a> <dropdown-value-regular-label
v-else
:key="label.id"
:label="label"
:label-filter-url="labelFilterUrl(label)"
:label-style="labelStyle(label)"
/>
</template>
</div> </div>
</template> </template>
<script>
import { GlLink, GlTooltip } from '@gitlab/ui';
export default {
components: {
GlTooltip,
GlLink,
},
props: {
label: {
type: Object,
required: true,
},
labelStyle: {
type: Object,
required: true,
},
labelFilterUrl: {
type: String,
required: true,
},
},
};
</script>
<template>
<a ref="regularLabelRef" :href="labelFilterUrl">
<span :style="labelStyle" class="badge color-label">
{{ label.title }}
</span>
<gl-tooltip :target="() => $refs.regularLabelRef" placement="top" boundary="viewport">
{{ label.description }}
</gl-tooltip>
</a>
</template>
<script>
import { GlLink, GlTooltip } from '@gitlab/ui';
export default {
components: {
GlTooltip,
GlLink,
},
props: {
label: {
type: Object,
required: true,
},
labelStyle: {
type: Object,
required: true,
},
scopedLabelsDocumentationLink: {
type: String,
required: true,
},
labelFilterUrl: {
type: String,
required: true,
},
},
};
</script>
<template>
<span class="d-inline-block position-relative scoped-label-wrapper">
<a :href="labelFilterUrl">
<span :ref="`labelTitleRef`" :style="labelStyle" class="badge color-label label">
{{ label.title }}
</span>
<gl-tooltip :target="() => $refs.labelTitleRef" placement="top" boundary="viewport">
<span class="font-weight-bold scoped-label-tooltip-title">{{ __('Scoped label') }}</span
><br />
{{ label.description }}
</gl-tooltip>
</a>
<gl-link :href="scopedLabelsDocumentationLink" target="_blank" class="label scoped-label"
><i class="fa fa-question-circle" :style="labelStyle"></i
></gl-link>
</span>
</template>
...@@ -110,6 +110,16 @@ ...@@ -110,6 +110,16 @@
font-size: 0; font-size: 0;
margin-bottom: -5px; margin-bottom: -5px;
} }
.scoped-label-wrapper {
.color-label {
padding-right: $gl-padding-24;
}
.scoped-label {
right: 12px;
}
}
} }
.right-sidebar { .right-sidebar {
......
...@@ -402,3 +402,39 @@ ...@@ -402,3 +402,39 @@
.priority-labels-empty-state .svg-content img { .priority-labels-empty-state .svg-content img {
max-width: $priority-label-empty-state-width; max-width: $priority-label-empty-state-width;
} }
.scoped-label-tooltip-title {
color: $indigo-300;
}
.scoped-label-wrapper {
&.label-link .color-label a {
color: inherit;
}
.color-label {
padding-right: $gl-padding-24;
}
.scoped-label {
position: absolute;
top: 4px;
right: 8px;
padding: 0;
margin: 0;
line-height: $gl-line-height;
}
}
// Label inside title of Delete Label Modal
.modal-header .page-title {
.scoped-label-wrapper {
.scoped-label {
line-height: 20px;
}
span.color-label {
padding-right: $gl-padding-24;
}
}
}
...@@ -7,6 +7,9 @@ module IssuableActions ...@@ -7,6 +7,9 @@ module IssuableActions
included do included do
before_action :authorize_destroy_issuable!, only: :destroy before_action :authorize_destroy_issuable!, only: :destroy
before_action :authorize_admin_issuable!, only: :bulk_update before_action :authorize_admin_issuable!, only: :bulk_update
before_action only: :show do
push_frontend_feature_flag(:scoped_labels, default_enabled: true)
end
end end
def permitted_keys def permitted_keys
......
...@@ -46,7 +46,7 @@ module LabelsHelper ...@@ -46,7 +46,7 @@ module LabelsHelper
if block_given? if block_given?
link_to link, class: css_class, &block link_to link, class: css_class, &block
else else
link_to render_colored_label(label, tooltip: tooltip), link, class: css_class render_label(label, tooltip: tooltip, link: link, css: css_class)
end end
end end
...@@ -78,19 +78,33 @@ module LabelsHelper ...@@ -78,19 +78,33 @@ module LabelsHelper
end end
end end
def render_colored_label(label, label_suffix = '', tooltip: true) def render_label(label, tooltip: true, link: nil, css: nil)
# if scoped label is used then EE wraps label tag with scoped label
# doc link
html = render_colored_label(label, tooltip: tooltip)
html = link_to(html, link, class: css) if link
html
end
def render_colored_label(label, label_suffix: '', tooltip: true, title: nil)
text_color = text_color_for_bg(label.color) text_color = text_color_for_bg(label.color)
title ||= tooltip ? label_tooltip_title(label) : ''
# Intentionally not using content_tag here so that this method can be called # Intentionally not using content_tag here so that this method can be called
# by LabelReferenceFilter # by LabelReferenceFilter
span = %(<span class="badge color-label #{"has-tooltip" if tooltip}" ) + span = %(<span class="badge color-label #{"has-tooltip" if tooltip}" ) +
%(style="background-color: #{label.color}; color: #{text_color}" ) + %(data-html="true" style="background-color: #{label.color}; color: #{text_color}" ) +
%(title="#{escape_once(label.description)}" data-container="body">) + %(title="#{escape_once(title)}" data-container="body">) +
%(#{escape_once(label.name)}#{label_suffix}</span>) %(#{escape_once(label.name)}#{label_suffix}</span>)
span.html_safe span.html_safe
end end
def label_tooltip_title(label)
label.description
end
def suggested_colors def suggested_colors
[ [
'#0033CC', '#0033CC',
...@@ -231,6 +245,39 @@ module LabelsHelper ...@@ -231,6 +245,39 @@ module LabelsHelper
labels.sort_by(&:title) labels.sort_by(&:title)
end end
def label_dropdown_data(project, opts = {})
{
toggle: "dropdown",
field_name: opts[:field_name] || "label_name[]",
show_no: "true",
show_any: "true",
project_id: project&.try(:id),
namespace_path: project&.try(:namespace)&.try(:full_path),
project_path: project&.try(:path)
}.merge(opts)
end
def sidebar_label_dropdown_data(issuable_type, issuable_sidebar)
label_dropdown_data(nil, {
default_label: "Labels",
field_name: "#{issuable_type}[label_names][]",
ability_name: issuable_type,
namespace_path: issuable_sidebar[:namespace_path],
project_path: issuable_sidebar[:project_path],
issue_update: issuable_sidebar[:issuable_json_path],
labels: issuable_sidebar[:project_labels_path],
display: 'static'
})
end
def label_from_hash(hash)
klass = hash[:group_id] ? GroupLabel : ProjectLabel
klass.new(hash.slice(:color, :description, :title, :group_id, :project_id))
end
# Required for Banzai::Filter::LabelReferenceFilter # Required for Banzai::Filter::LabelReferenceFilter
module_function :render_colored_label, :text_color_for_bg, :escape_once module_function :render_colored_label, :text_color_for_bg, :escape_once, :label_tooltip_title
end end
LabelsHelper.prepend(EE::LabelsHelper)
...@@ -4,7 +4,7 @@ class GlobalLabel ...@@ -4,7 +4,7 @@ class GlobalLabel
attr_accessor :title, :labels attr_accessor :title, :labels
alias_attribute :name, :title alias_attribute :name, :title
delegate :color, :text_color, :description, to: :@first_label delegate :color, :text_color, :description, :scoped_label?, to: :@first_label
def for_display def for_display
@first_label @first_label
......
...@@ -258,3 +258,5 @@ class Label < ApplicationRecord ...@@ -258,3 +258,5 @@ class Label < ApplicationRecord
%w(color title).each { |attr| self[attr] = self[attr]&.strip } %w(color title).each { |attr| self[attr] = self[attr]&.strip }
end end
end end
Label.prepend(EE::Label)
...@@ -104,3 +104,5 @@ class IssuableSidebarBasicEntity < Grape::Entity ...@@ -104,3 +104,5 @@ class IssuableSidebarBasicEntity < Grape::Entity
request.current_user request.current_user
end end
end end
IssuableSidebarBasicEntity.prepend(EE::IssuableSidebarBasicEntity)
...@@ -107,12 +107,13 @@ class IssuableBaseService < BaseService ...@@ -107,12 +107,13 @@ class IssuableBaseService < BaseService
@labels_service ||= ::Labels::AvailableLabelsService.new(current_user, parent, params) @labels_service ||= ::Labels::AvailableLabelsService.new(current_user, parent, params)
end end
def process_label_ids(attributes, existing_label_ids: nil) def process_label_ids(attributes, existing_label_ids: nil, extra_label_ids: [])
label_ids = attributes.delete(:label_ids) label_ids = attributes.delete(:label_ids)
add_label_ids = attributes.delete(:add_label_ids) add_label_ids = attributes.delete(:add_label_ids)
remove_label_ids = attributes.delete(:remove_label_ids) remove_label_ids = attributes.delete(:remove_label_ids)
new_label_ids = existing_label_ids || label_ids || [] new_label_ids = existing_label_ids || label_ids || []
new_label_ids |= extra_label_ids
if add_label_ids.blank? && remove_label_ids.blank? if add_label_ids.blank? && remove_label_ids.blank?
new_label_ids = label_ids if label_ids new_label_ids = label_ids if label_ids
...@@ -147,7 +148,7 @@ class IssuableBaseService < BaseService ...@@ -147,7 +148,7 @@ class IssuableBaseService < BaseService
params.delete(:state_event) params.delete(:state_event)
params[:author] ||= current_user params[:author] ||= current_user
params[:label_ids] = issuable.label_ids.to_a + process_label_ids(params) params[:label_ids] = process_label_ids(params, extra_label_ids: issuable.label_ids.to_a)
issuable.assign_attributes(params) issuable.assign_attributes(params)
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
.modal-dialog .modal-dialog
.modal-content .modal-content
.modal-header .modal-header
%h3.page-title Delete #{render_colored_label(label, tooltip: false)} ? %h3.page-title Delete #{render_label(label, tooltip: false)} ?
%button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') } %button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') }
%span{ "aria-hidden": true } &times; %span{ "aria-hidden": true } &times;
......
...@@ -8,7 +8,7 @@ ...@@ -8,7 +8,7 @@
- if defined?(@project) - if defined?(@project)
= link_to_label(label, subject: @project, tooltip: false) = link_to_label(label, subject: @project, tooltip: false)
- else - else
= render_colored_label(label, tooltip: false) = render_label(label, tooltip: false)
.label-description .label-description
.append-right-default.prepend-left-default .append-right-default.prepend-left-default
- if label.description.present? - if label.description.present?
......
...@@ -21,13 +21,7 @@ ...@@ -21,13 +21,7 @@
%button.dropdown-menu-toggle.js-label-select.js-multiselect.js-issue-board-sidebar{ type: "button", %button.dropdown-menu-toggle.js-label-select.js-multiselect.js-issue-board-sidebar{ type: "button",
":data-selected" => "selectedLabels", ":data-selected" => "selectedLabels",
":data-labels" => "issue.assignableLabelsEndpoint", ":data-labels" => "issue.assignableLabelsEndpoint",
data: { toggle: "dropdown", data: label_dropdown_data(@project, namespace_path: @namespace_path, field_name: "issue[label_names][]") }
field_name: "issue[label_names][]",
show_no: "true",
show_any: "true",
project_id: @project&.try(:id),
namespace_path: @namespace_path,
project_path: @project.try(:path) } }
%span.dropdown-toggle-text %span.dropdown-toggle-text
{{ labelDropdownTitle }} {{ labelDropdownTitle }}
= icon('chevron-down') = icon('chevron-down')
......
...@@ -8,7 +8,7 @@ ...@@ -8,7 +8,7 @@
- classes = local_assigns.fetch(:classes, []) - classes = local_assigns.fetch(:classes, [])
- selected = local_assigns.fetch(:selected, nil) - selected = local_assigns.fetch(:selected, nil)
- dropdown_title = local_assigns.fetch(:dropdown_title, "Filter by label") - dropdown_title = local_assigns.fetch(:dropdown_title, "Filter by label")
- dropdown_data = {toggle: 'dropdown', field_name: "label_name[]", show_no: "true", show_any: "true", namespace_path: @project.try(:namespace).try(:full_path), project_path: @project.try(:path), labels: labels_filter_path_with_defaults, default_label: "Labels"} - dropdown_data = label_dropdown_data(@project, labels: labels_filter_path_with_defaults, default_label: "Labels")
- dropdown_data.merge!(data_options) - dropdown_data.merge!(data_options)
- label_name = local_assigns.fetch(:label_name, "Labels") - label_name = local_assigns.fetch(:label_name, "Labels")
- no_default_styles = local_assigns.fetch(:no_default_styles, false) - no_default_styles = local_assigns.fetch(:no_default_styles, false)
......
...@@ -105,10 +105,8 @@ ...@@ -105,10 +105,8 @@
= link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link float-right' = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link float-right'
.value.issuable-show-labels.dont-hide.hide-collapsed.qa-labels-block{ class: ("has-labels" if selected_labels.any?) } .value.issuable-show-labels.dont-hide.hide-collapsed.qa-labels-block{ class: ("has-labels" if selected_labels.any?) }
- if selected_labels.any? - if selected_labels.any?
- selected_labels.each do |label| - selected_labels.each do |label_hash|
= link_to sidebar_label_filter_path(issuable_sidebar[:project_issuables_path], label[:title]) do = render_label(label_from_hash(label_hash), link: sidebar_label_filter_path(issuable_sidebar[:project_issuables_path], label_hash[:title]))
%span.badge.color-label.has-tooltip{ style: "background-color: #{label[:color]}; color: #{label[:text_color]}", title: label[:description], data: { container: "body" } }
= label[:title]
- else - else
%span.no-value %span.no-value
= _('None') = _('None')
...@@ -116,7 +114,7 @@ ...@@ -116,7 +114,7 @@
- selected_labels.each do |label| - selected_labels.each do |label|
= hidden_field_tag "#{issuable_type}[label_names][]", label[:id], id: nil = hidden_field_tag "#{issuable_type}[label_names][]", label[:id], id: nil
.dropdown .dropdown
%button.dropdown-menu-toggle.js-label-select.js-multiselect.js-label-sidebar-dropdown{ type: "button", data: {toggle: "dropdown", default_label: "Labels", field_name: "#{issuable_type}[label_names][]", ability_name: issuable_type, show_no: "true", show_any: "true", namespace_path: issuable_sidebar[:namespace_path], project_path: issuable_sidebar[:project_path], issue_update: issuable_sidebar[:issuable_json_path], labels: issuable_sidebar[:project_labels_path], display: 'static' } } %button.dropdown-menu-toggle.js-label-select.js-multiselect.js-label-sidebar-dropdown{ type: "button", data: sidebar_label_dropdown_data(issuable_type, issuable_sidebar) }
%span.dropdown-toggle-text{ class: ("is-default" if selected_labels.empty?) } %span.dropdown-toggle-text{ class: ("is-default" if selected_labels.empty?) }
= multi_label_name(selected_labels, "Labels") = multi_label_name(selected_labels, "Labels")
= icon('chevron-down', 'aria-hidden': 'true') = icon('chevron-down', 'aria-hidden': 'true')
......
...@@ -4,7 +4,9 @@ ...@@ -4,7 +4,9 @@
.form-group.row .form-group.row
= f.label :title, class: 'col-form-label col-sm-2' = f.label :title, class: 'col-form-label col-sm-2'
.col-sm-10 .col-sm-10
= f.text_field :title, class: "form-control qa-label-title", required: true, autofocus: true = f.text_field :title, class: "form-control js-label-title qa-label-title", required: true, autofocus: true
= render_if_exists 'shared/labels/create_label_help_text'
.form-group.row .form-group.row
= f.label :description, class: 'col-form-label col-sm-2' = f.label :description, class: 'col-form-label col-sm-2'
.col-sm-10 .col-sm-10
......
...@@ -21,8 +21,7 @@ ...@@ -21,8 +21,7 @@
%span.issuable-number= issuable.to_reference %span.issuable-number= issuable.to_reference
- labels.each do |label| - labels.each do |label|
= link_to polymorphic_path(issuable_type_args, { milestone_title: @milestone.title, label_name: label.title, state: 'all' }) do = render_label(label, link: polymorphic_path(issuable_type_args, { milestone_title: @milestone.title, label_name: label.title, state: 'all' }))
- render_colored_label(label)
%span.assignee-icon %span.assignee-icon
- assignees.each do |assignee| - assignees.each do |assignee|
......
...@@ -5,8 +5,7 @@ ...@@ -5,8 +5,7 @@
%li.is-not-draggable %li.is-not-draggable
%span.label-row %span.label-row
%span.label-name %span.label-name
= link_to milestones_label_path(options) do = render_label(label, tooltip: false, link: milestones_label_path(options))
- render_colored_label(label, tooltip: false)
%span.prepend-description-left %span.prepend-description-left
= markdown_field(label, :description) = markdown_field(label, :description)
......
...@@ -11,6 +11,13 @@ In GitLab, you can create project and group labels: ...@@ -11,6 +11,13 @@ In GitLab, you can create project and group labels:
- **Project labels** can be assigned to issues or merge requests in that project only. - **Project labels** can be assigned to issues or merge requests in that project only.
- **Group labels** can be assigned to any issue or merge request of any project in that group or any subgroups of the group. - **Group labels** can be assigned to any issue or merge request of any project in that group or any subgroups of the group.
## Scoped labels
A scoped label is a specific kind of label defined only by a special colon syntax in the label’s title, namely `key::value`. There is no different way to create and otherwise manage scoped labels. A label is a scoped label when its title follows the syntax.
![A sample scoped label](img/key_value_labels.png)
A scoped label with a same key but with different value cannot be applied together to an Issue, Epic or a Merge Request. For example, if an issue has `priority::3` already and you apply `priority::2` to it, `priority::3` automatically gets removed from the issue.
## Creating labels ## Creating labels
>**Note:** >**Note:**
...@@ -49,6 +56,12 @@ From the sidebar of an issue or a merge request, you can create a create a new * ...@@ -49,6 +56,12 @@ From the sidebar of an issue or a merge request, you can create a create a new *
![Labels inline](img/new_label_from_sidebar.gif) ![Labels inline](img/new_label_from_sidebar.gif)
### New scoped label
To create a scoped label, follow similar steps from above. The only difference is to include double-colon `::` character in the title of the Label when created or edited.
In the case where labels have multiple sets of `::`, the longest path is used as the key. For example, we would check for exclusivity on `some::key::` level instead of `some::` on label `some::key::value`.
## Editing labels ## Editing labels
NOTE: **Note:** NOTE: **Note:**
......
...@@ -61,6 +61,16 @@ export default { ...@@ -61,6 +61,16 @@ export default {
required: false, required: false,
default: () => [], default: () => [],
}, },
enableScopedLabels: {
type: Boolean,
required: false,
default: false,
},
scopedLabelsDocumentationLink: {
type: String,
required: false,
default: '#',
},
}, },
data() { data() {
return { return {
...@@ -244,6 +254,8 @@ export default { ...@@ -244,6 +254,8 @@ export default {
:context="board" :context="board"
:labels-path="labelsPath" :labels-path="labelsPath"
:can-edit="canAdminBoard" :can-edit="canAdminBoard"
:scoped-labels-documentation-link="scopedLabelsDocumentationLink"
:enable-scoped-labels="enableScopedLabels"
ability-name="issue" ability-name="issue"
@onLabelClick="handleLabelClick" @onLabelClick="handleLabelClick"
> >
......
...@@ -80,6 +80,16 @@ export default { ...@@ -80,6 +80,16 @@ export default {
type: Array, type: Array,
required: true, required: true,
}, },
enabledScopedLabels: {
type: Boolean,
required: false,
default: false,
},
scopedLabelsDocumentationLink: {
type: String,
required: false,
default: '#',
},
}, },
data() { data() {
return { return {
...@@ -346,6 +356,8 @@ export default { ...@@ -346,6 +356,8 @@ export default {
:can-admin-board="canAdminBoard" :can-admin-board="canAdminBoard"
:scoped-issue-board-feature-enabled="scopedIssueBoardFeatureEnabled" :scoped-issue-board-feature-enabled="scopedIssueBoardFeatureEnabled"
:weights="weights" :weights="weights"
:enable-scoped-labels="enabledScopedLabels"
:scoped-labels-documentation-link="scopedLabelsDocumentationLink"
/> />
</span> </span>
</div> </div>
......
...@@ -33,6 +33,8 @@ export default { ...@@ -33,6 +33,8 @@ export default {
'labelsPath', 'labelsPath',
'labelsWebUrl', 'labelsWebUrl',
'epicsWebUrl', 'epicsWebUrl',
'scopedLabels',
'scopedLabelsDocumentationLink',
]), ]),
epicContext() { epicContext() {
return { return {
...@@ -111,6 +113,8 @@ export default { ...@@ -111,6 +113,8 @@ export default {
:labels-web-url="labelsWebUrl" :labels-web-url="labelsWebUrl"
:label-filter-base-path="epicsWebUrl" :label-filter-base-path="epicsWebUrl"
:show-create="true" :show-create="true"
:enable-scoped-labels="scopedLabels"
:scoped-labels-documentation-link="scopedLabelsDocumentationLink"
ability-name="epic" ability-name="epic"
@onLabelClick="handleLabelClick" @onLabelClick="handleLabelClick"
@onDropdownClose="handleDropdownClose" @onDropdownClose="handleDropdownClose"
......
import Labels from '~/labels';
import $ from 'jquery';
class LabelsEE extends Labels {
addBinding() {
$(document).on('input', 'input.js-label-title', this.showSuggestedText);
super.addBinding();
}
showSuggestedText() {
const title = $(this).val();
const $parentEl = $('.label-form');
const hasKeyValue = $parentEl.find('.js-has-scoped-labels');
const useKeyValue = $parentEl.find('.js-use-scoped-labels');
const isKeyVal = title.indexOf('::') === -1;
hasKeyValue.toggleClass('hidden', isKeyVal);
useKeyValue.toggleClass('hidden', !isKeyVal);
}
}
export default LabelsEE;
...@@ -31,7 +31,9 @@ module EE ...@@ -31,7 +31,9 @@ module EE
board_weight: board.weight, board_weight: board.weight,
focus_mode_available: parent.feature_available?(:issue_board_focus_mode), focus_mode_available: parent.feature_available?(:issue_board_focus_mode),
weight_feature_available: parent.feature_available?(:issue_weights).to_s, weight_feature_available: parent.feature_available?(:issue_weights).to_s,
show_promotion: show_feature_promotion show_promotion: show_feature_promotion,
scoped_labels: parent.feature_available?(:scoped_labels),
scoped_labels_documentation_link: help_page_path('user/project/labels.md', anchor: 'scoped-labels')
} }
super.merge(data) super.merge(data)
......
# frozen_string_literal: true
module EE
module LabelsHelper
def render_label(label, tooltip: true, link: nil, css: nil)
content = super
content = scoped_label_wrapper(content, label) if label.scoped_label?
content
end
def scoped_label_wrapper(link, label)
%(<span class="d-inline-block position-relative scoped-label-wrapper">#{link}#{scoped_labels_doc_link(label)}</span>).html_safe
end
def scoped_labels_doc_link(label)
text_color = ::LabelsHelper.text_color_for_bg(label.color)
content = %(<i class="fa fa-question-circle" style="background-color: #{label.color}; color: #{text_color}"></i>)
help_url = ::Gitlab::Routing.url_helpers.help_page_url('user/project/labels.md', anchor: 'scoped-labels')
%(<a href="#{help_url}" class="label scoped-label" target="_blank" rel="noopener">#{content}</a>)
end
def label_tooltip_title(label)
# can't use `super` because this is called also as a module method from
# banzai
tooltip = ::LabelsHelper.label_tooltip_title(label)
tooltip = %(<span class='font-weight-bold scoped-label-tooltip-title'>Scoped label</span><br />#{tooltip}) if label.scoped_label?
tooltip
end
def label_dropdown_data(project, opts = {})
super.merge({
scoped_labels: project&.feature_available?(:scoped_labels)&.to_s,
scoped_labels_documentation_link: help_page_path('user/project/labels.md', anchor: 'scoped-labels')
})
end
def sidebar_label_dropdown_data(issuable_type, issuable_sidebar)
super.merge({
scoped_labels: issuable_sidebar[:scoped_labels_available].to_s
})
end
module_function :scoped_label_wrapper, :scoped_labels_doc_link, :label_tooltip_title
end
end
...@@ -40,7 +40,9 @@ module EpicsHelper ...@@ -40,7 +40,9 @@ module EpicsHelper
labels_path: group_labels_path(group, format: :json, only_group_labels: true, include_ancestor_groups: true), labels_path: group_labels_path(group, format: :json, only_group_labels: true, include_ancestor_groups: true),
toggle_subscription_path: toggle_subscription_group_epic_path(group, epic), toggle_subscription_path: toggle_subscription_group_epic_path(group, epic),
labels_web_url: group_labels_path(group), labels_web_url: group_labels_path(group),
epics_web_url: group_epics_path(group) epics_web_url: group_epics_path(group),
scoped_labels: group.feature_available?(:scoped_labels),
scoped_labels_documentation_link: help_page_path('user/project/labels.md', anchor: 'scoped-labels')
} }
epic_meta[:todo_delete_path] = dashboard_todo_path(todo) if todo.present? epic_meta[:todo_delete_path] = dashboard_todo_path(todo) if todo.present?
......
# frozen_string_literal: true
module EE
module Label
extend ActiveSupport::Concern
SCOPED_LABEL_PATTERN = /^.*::/.freeze
def scoped_label?
SCOPED_LABEL_PATTERN.match?(name) && subject.feature_available?(:scoped_labels)
end
def scoped_label_key
title[Label::SCOPED_LABEL_PATTERN]
end
end
end
...@@ -52,6 +52,7 @@ class License < ApplicationRecord ...@@ -52,6 +52,7 @@ class License < ApplicationRecord
geo geo
github_project_service_integration github_project_service_integration
jira_dev_panel_integration jira_dev_panel_integration
scoped_labels
ldap_group_sync_filter ldap_group_sync_filter
multiple_clusters multiple_clusters
multiple_group_issue_boards multiple_group_issue_boards
......
# frozen_string_literal: true
class ScopedLabelSet
attr_reader :labels, :key
def self.from_label_ids(ids)
by_key = Hash.new { |hash, key| hash[key] = new(key) }
labels = Label.select(:id, :title).where(id: ids)
labels.each do |label|
key = label.scoped_label_key
by_key[key].add(label)
end
by_key.values
end
def initialize(key, labels = [])
@key = key
@labels = labels
end
def add(label)
labels << label
end
def last_id_by_order(label_ids_order)
by_index = label_ids_order.map.with_index { |id, idx| [id.to_i, idx] }.to_h
label_ids.max do |id1, id2|
by_index[id1].to_i <=> by_index[id2].to_i
end
end
def valid?
key.nil? || labels.count < 2
end
def contains_any?(ids)
(label_ids & ids).present?
end
def label_ids
labels.map(&:id)
end
end
# frozen_string_literal: true
module EE
module IssuableSidebarBasicEntity
extend ActiveSupport::Concern
prepended do
expose :scoped_labels_available do |issuable|
issuable.project&.feature_available?(:scoped_labels)
end
end
end
end
...@@ -5,6 +5,8 @@ module EE ...@@ -5,6 +5,8 @@ module EE
extend ::Gitlab::Utils::Override extend ::Gitlab::Utils::Override
include ::Gitlab::Utils::StrongMemoize include ::Gitlab::Utils::StrongMemoize
attr_reader :label_ids_ordered_by_selection
private private
override :filter_params override :filter_params
...@@ -20,10 +22,39 @@ module EE ...@@ -20,10 +22,39 @@ module EE
super super
end end
override :filter_labels
def filter_labels
@label_ids_ordered_by_selection = params[:add_label_ids].to_a + params[:label_ids].to_a # rubocop:disable Gitlab/ModuleWithInstanceVariables
super
end
def update_task_event? def update_task_event?
strong_memoize(:update_task_event) do strong_memoize(:update_task_event) do
params.key?(:update_task) params.key?(:update_task)
end end
end end
override :process_label_ids
def process_label_ids(attributes, existing_label_ids: nil, extra_label_ids: [])
ids = super
added_label_ids = ids - existing_label_ids.to_a
filter_mutually_exclusive_labels(ids, added_label_ids)
end
def filter_mutually_exclusive_labels(ids, added_label_ids)
return ids if added_label_ids.empty? || !parent.feature_available?(:scoped_labels)
label_sets = ScopedLabelSet.from_label_ids(ids)
label_sets.map do |set|
if set.valid? || !set.contains_any?(added_label_ids)
set.label_ids
else
set.last_id_by_order(label_ids_ordered_by_selection)
end
end.flatten
end
end end
end end
...@@ -15,4 +15,4 @@ ...@@ -15,4 +15,4 @@
- if epic.labels.any? - if epic.labels.any?
&nbsp; &nbsp;
- epic.labels.each do |label| - epic.labels.each do |label|
= link_to render_colored_label(label, tooltip: true), group_epics_path(@group, label_name:[label.name]), class: 'label-link' = render_label(label, tooltip: true, link: group_epics_path(@group, label_name:[label.name]), css: 'label-link')
- docs_link = help_page_path('user/project/labels.md', anchor: 'scoped-labels')
.col-sm-10.offset-sm-2.form-text.text-muted.js-has-scoped-labels.hidden
Using
%code ::
denotes a
%a{ href: docs_link, target: '_blank', rel: 'noopener noreferrer' } scoped label set
.col-sm-10.offset-sm-2.form-text.text-muted.js-use-scoped-labels
Use
%code ::
to create a
%a{ href: docs_link, target: '_blank', rel: 'noopener noreferrer' } scoped label set
(eg.
%code priority::1
)
---
title: Added mutually exclusive key value labels
merge_request:
author:
type: added
# frozen_string_literal: true
module EE
module Banzai
module Filter
module LabelReferenceFilter
extend ::Gitlab::Utils::Override
override :wrap_link
def wrap_link(link, label)
content = super
content = ::EE::LabelsHelper.scoped_label_wrapper(content, label) if label.scoped_label?
content
end
def tooltip_title(label)
::EE::LabelsHelper.label_tooltip_title(label)
end
end
end
end
end
{
"allOf": [
{ "$ref": "../../../../../../spec/fixtures/api/schemas/entities/merge_request_sidebar.json" },
{
"properties": {
"scoped_labels_available": { "type": "boolean" }
},
"required": [
"scoped_labels_available"
]
}
]
}
# frozen_string_literal: true
require 'spec_helper'
describe LabelsHelper do
set(:project) { create(:project) }
set(:label) { create(:label, project: project) }
set(:scoped_label) { create(:label, name: 'key::value', project: project) }
describe '#render_label' do
context 'with scoped labels enabled' do
before do
stub_licensed_features(scoped_labels: true)
end
it 'includes link to scoped labels documentation' do
expect(render_label(scoped_label)).to match(%r(<span.+>#{scoped_label.name}</span><a.+>.*question-circle.*</a>))
end
it 'does not include link to scoped label documentation for common labels' do
expect(render_label(label)).to match(%r(<span.+>#{label.name}</span>$))
end
end
context 'with scoped labels disabled' do
before do
stub_licensed_features(scoped_labels: false)
end
it 'does not include link to scoped documentation' do
expect(render_label(scoped_label)).to match(%r(<span.+>#{scoped_label.name}</span>$))
end
end
end
end
...@@ -39,7 +39,7 @@ describe EpicsHelper do ...@@ -39,7 +39,7 @@ describe EpicsHelper do
start_date_sourcing_milestone_title start_date_sourcing_milestone_dates start_date_sourcing_milestone_title start_date_sourcing_milestone_dates
due_date due_date_is_fixed due_date_fixed due_date_from_milestones due_date_sourcing_milestone_title due_date due_date_is_fixed due_date_fixed due_date_from_milestones due_date_sourcing_milestone_title
due_date_sourcing_milestone_dates end_date state namespace labels_path toggle_subscription_path due_date_sourcing_milestone_dates end_date state namespace labels_path toggle_subscription_path
labels_web_url epics_web_url lock_version labels_web_url epics_web_url lock_version scoped_labels scoped_labels_documentation_link
]) ])
expect(meta_data['author']).to eq({ expect(meta_data['author']).to eq({
'name' => user.name, 'name' => user.name,
...@@ -111,7 +111,7 @@ describe EpicsHelper do ...@@ -111,7 +111,7 @@ describe EpicsHelper do
start_date_sourcing_milestone_title start_date_sourcing_milestone_dates start_date_sourcing_milestone_title start_date_sourcing_milestone_dates
due_date due_date_is_fixed due_date_fixed due_date_from_milestones due_date_sourcing_milestone_title due_date due_date_is_fixed due_date_fixed due_date_from_milestones due_date_sourcing_milestone_title
due_date_sourcing_milestone_dates end_date state namespace labels_path toggle_subscription_path due_date_sourcing_milestone_dates end_date state namespace labels_path toggle_subscription_path
labels_web_url epics_web_url lock_version labels_web_url epics_web_url lock_version scoped_labels scoped_labels_documentation_link
]) ])
expect(meta_data['start_date']).to eq('2000-01-01') expect(meta_data['start_date']).to eq('2000-01-01')
expect(meta_data['start_date_sourcing_milestone_title']).to eq(milestone1.title) expect(meta_data['start_date_sourcing_milestone_title']).to eq(milestone1.title)
......
# frozen_string_literal: true
require 'spec_helper'
describe Banzai::Filter::LabelReferenceFilter do
include FilterSpecHelper
let(:project) { create(:project, :public, name: 'sample-project') }
let(:label) { create(:label, name: 'label', project: project) }
let(:scoped_label) { create(:label, name: 'key::value', project: project) }
context 'with scoped labels enabled' do
before do
stub_licensed_features(scoped_labels: true)
end
it 'includes link to scoped documentation' do
doc = reference_filter("See #{scoped_label.to_reference}")
expect(doc.to_html).to match(%r(<a.+><span.+>#{scoped_label.name}</span></a><a.+>.*question-circle.*</a>))
end
it 'does not include link to scoped documentation for common labels' do
doc = reference_filter("See #{label.to_reference}")
expect(doc.to_html).to match(%r(<a.+><span.+>#{label.name}</span></a>$))
end
end
context 'with scoped labels disabled' do
before do
stub_licensed_features(scoped_labels: false)
end
it 'does not include link to scoped labels documentation' do
doc = reference_filter("See #{scoped_label.to_reference}")
expect(doc.to_html).to match(%r(<a.+><span.+>#{scoped_label.name}</span></a>$))
end
end
end
# frozen_string_literal: true
require 'rails_helper'
describe ScopedLabelSet do
set(:kv_label1) { create(:label, title: 'key::label1') }
set(:kv_label2) { create(:label, title: 'key::label2') }
set(:kv_label3) { create(:label, title: 'key::label3') }
describe '.from_label_ids' do
def get_labels(sets, key)
sets.find { |set| set.key == key }.label_ids
end
it 'groups labels by their key' do
labels = [
create(:label, title: 'label1'),
create(:label, title: 'label2'),
create(:label, title: 'key::label1'),
create(:label, title: 'key::label2'),
create(:label, title: 'key::another key::label1'),
create(:label, title: 'key::another key::label2')
]
sets = described_class.from_label_ids(labels)
expect(sets.size).to eq 3
expect(get_labels(sets, nil)).to match_array([labels[0].id, labels[1].id])
expect(get_labels(sets, 'key::')).to match_array([labels[2].id, labels[3].id])
expect(get_labels(sets, 'key::another key::')).to match_array([labels[4].id, labels[5].id])
end
end
describe '#valid?' do
it 'returns true for not scoped labels' do
label1 = build(:label, title: 'label1')
label2 = build(:label, title: 'label2')
set = described_class.new(nil, [label1, label2])
expect(set.valid?).to eq(true)
end
it 'returns true for scoped labels with single label' do
set = described_class.new(nil, [kv_label1])
expect(set.valid?).to eq(true)
end
it 'returns false for scoped labels with multiple labels' do
set = described_class.new('key', [kv_label1, kv_label2])
expect(set.valid?).to eq(false)
end
end
describe '#add' do
it 'adds a label to the set' do
set = described_class.new('key')
set.add(kv_label1)
expect(set.labels).to eq([kv_label1])
end
end
describe '#contains_any?' do
it 'returns true if any of label ids is in set' do
set = described_class.new('key', [kv_label1, kv_label2])
expect(set.contains_any?([kv_label2.id])).to eq(true)
end
it 'returns true if any of label ids is in set' do
set = described_class.new('key', [kv_label1])
expect(set.contains_any?([kv_label2.id])).to eq(false)
end
end
describe '#last_id_by_order' do
it 'returns last label present in the set ordered by custom order of superset of label ids' do
set = described_class.new('key', [kv_label1, kv_label3])
expect(set.last_id_by_order([kv_label1.id, kv_label3.id, kv_label2.id])).to eq(kv_label3.id)
end
end
end
...@@ -31,4 +31,10 @@ describe Issues::CreateService do ...@@ -31,4 +31,10 @@ describe Issues::CreateService do
end end
end end
end end
describe '#execute' do
it_behaves_like 'new issuable with scoped labels' do
let(:parent) { project }
end
end
end end
...@@ -118,5 +118,10 @@ describe Issues::UpdateService do ...@@ -118,5 +118,10 @@ describe Issues::UpdateService do
end end
end end
end end
it_behaves_like 'existing issuable with scoped labels' do
let(:issuable) { issue }
let(:parent) { project }
end
end end
end end
...@@ -52,5 +52,9 @@ describe MergeRequests::CreateService do ...@@ -52,5 +52,9 @@ describe MergeRequests::CreateService do
end end
end end
end end
it_behaves_like 'new issuable with scoped labels' do
let(:parent) { project }
end
end end
end end
# frozen_string_literal: true
require 'spec_helper'
describe MergeRequests::UpdateService do
include ProjectForksHelper
let(:project) { create(:project, :repository) }
let(:user) { create(:user) }
let(:service) { described_class.new(project, user, {}) }
let(:merge_request) do
create(:merge_request, :simple, title: 'Old title',
source_project: project,
author: user)
end
before do
allow(service).to receive(:execute_hooks)
end
describe '#execute' do
it_behaves_like 'existing issuable with scoped labels' do
let(:issuable) { merge_request }
let(:parent) { project }
end
end
end
...@@ -19,5 +19,9 @@ describe Epics::CreateService do ...@@ -19,5 +19,9 @@ describe Epics::CreateService do
expect(epic.description).to eq('epic description') expect(epic.description).to eq('epic description')
expect(NewEpicWorker).to have_received(:perform_async).with(epic.id, user.id) expect(NewEpicWorker).to have_received(:perform_async).with(epic.id, user.id)
end end
it_behaves_like 'new issuable with scoped labels' do
let(:parent) { group }
end
end end
end end
...@@ -211,5 +211,10 @@ describe Epics::UpdateService do ...@@ -211,5 +211,10 @@ describe Epics::UpdateService do
end end
end end
end end
it_behaves_like 'existing issuable with scoped labels' do
let(:issuable) { epic }
let(:parent) { group }
end
end end
end end
# frozen_string_literal: true
shared_context 'exclusive labels creation' do
def create_label(title)
if parent.is_a?(Group)
create(:group_label, group: parent, title: title)
else
create(:label, project: parent, title: title)
end
end
before do
parent.add_developer(user)
end
end
shared_examples_for 'new issuable with scoped labels' do
include_context 'exclusive labels creation' do
context 'scoped labels are avaialble' do
before do
stub_licensed_features(scoped_labels: true)
end
it 'adds only last selected exclusive scoped label' do
label1 = create_label('label1')
label2 = create_label('key::label1')
label3 = create_label('key::label2')
label4 = create_label('key::label3')
issuable = described_class.new(
parent, user, title: 'test', label_ids: [label1.id, label3.id, label4.id, label2.id]
).execute
expect(issuable.labels).to match_array([label1, label2])
end
end
context 'scoped labels are not available' do
before do
stub_licensed_features(scoped_labels: false)
end
it 'adds only last selected exclusive scoped label' do
label1 = create_label('label1')
label2 = create_label('key::label1')
label3 = create_label('key::label2')
label4 = create_label('key::label3')
issuable = described_class.new(
parent, user, title: 'test', label_ids: [label1.id, label3.id, label4.id, label2.id]
).execute
expect(issuable.labels).to match_array([label1, label2, label3, label4])
end
end
end
end
shared_examples_for 'existing issuable with scoped labels' do
include_context 'exclusive labels creation' do
let(:label1) { create_label('key::label1') }
let(:label2) { create_label('key::label2') }
let(:label3) { create_label('key::label3') }
context 'scoped labels are avaialble' do
before do
stub_licensed_features(scoped_labels: true, epics: true)
end
it 'adds only last selected exclusive scoped label' do
create(:label_link, label: label1, target: issuable)
create(:label_link, label: label2, target: issuable)
issuable.reload
described_class.new(
parent, user, label_ids: [label1.id, label3.id]
).execute(issuable)
expect(issuable.reload.labels).to match_array([label3])
end
it 'it preserves multiple exclusive scoped labels when only removing labels' do
create(:label_link, label: label1, target: issuable)
create(:label_link, label: label2, target: issuable)
create(:label_link, label: label3, target: issuable)
issuable.reload
described_class.new(
parent, user, label_ids: [label2.id, label3.id]
).execute(issuable)
expect(issuable.reload.labels).to match_array([label2, label3])
end
end
context 'scoped labels are not available' do
before do
stub_licensed_features(scoped_labels: false, epics: true)
end
it 'adds all scoped labels' do
create(:label_link, label: label1, target: issuable)
create(:label_link, label: label2, target: issuable)
issuable.reload
described_class.new(
parent, user, label_ids: [label1.id, label2.id, label3.id]
).execute(issuable)
expect(issuable.reload.labels).to match_array([label1, label2, label3])
end
end
end
end
...@@ -195,15 +195,21 @@ module Banzai ...@@ -195,15 +195,21 @@ module Banzai
content = link_content || object_link_text(object, matches) content = link_content || object_link_text(object, matches)
%(<a href="#{url}" #{data} link = %(<a href="#{url}" #{data}
title="#{escape_once(title)}" title="#{escape_once(title)}"
class="#{klass}">#{content}</a>) class="#{klass}">#{content}</a>)
wrap_link(link, object)
else else
match match
end end
end end
end end
def wrap_link(link, object)
link
end
def data_attributes_for(text, parent, object, link_content: false, link_reference: false) def data_attributes_for(text, parent, object, link_content: false, link_reference: false)
object_parent_type = parent.is_a?(Group) ? :group : :project object_parent_type = parent.is_a?(Group) ? :group : :project
......
...@@ -91,7 +91,11 @@ module Banzai ...@@ -91,7 +91,11 @@ module Banzai
label_suffix = " <i>in #{reference}</i>" if reference.present? label_suffix = " <i>in #{reference}</i>" if reference.present?
end end
LabelsHelper.render_colored_label(object, label_suffix) LabelsHelper.render_colored_label(object, label_suffix: label_suffix, title: tooltip_title(object))
end
def tooltip_title(label)
nil
end end
def full_path_ref?(matches) def full_path_ref?(matches)
...@@ -113,3 +117,5 @@ module Banzai ...@@ -113,3 +117,5 @@ module Banzai
end end
end end
end end
Banzai::Filter::LabelReferenceFilter.prepend(EE::Banzai::Filter::LabelReferenceFilter)
...@@ -9209,6 +9209,9 @@ msgstr "" ...@@ -9209,6 +9209,9 @@ msgstr ""
msgid "Scoped issue boards" msgid "Scoped issue boards"
msgstr "" msgstr ""
msgid "Scoped label"
msgstr ""
msgid "Scroll down to <strong>Google Code Project Hosting</strong> and enable the switch on the right." msgid "Scroll down to <strong>Google Code Project Hosting</strong> and enable the switch on the right."
msgstr "" msgstr ""
......
...@@ -51,6 +51,5 @@ ...@@ -51,6 +51,5 @@
"toggle_subscription_path": { "type": "string" }, "toggle_subscription_path": { "type": "string" },
"move_issue_path": { "type": "string" }, "move_issue_path": { "type": "string" },
"projects_autocomplete_path": { "type": "string" } "projects_autocomplete_path": { "type": "string" }
}, }
"additionalProperties": false
} }
...@@ -13,40 +13,104 @@ const mockLabels = [ ...@@ -13,40 +13,104 @@ const mockLabels = [
}, },
]; ];
const mockScopedLabels = [
{
id: 27,
title: 'Foo::Bar',
description: 'Foobar',
color: '#333ABC',
text_color: '#FFFFFF',
},
];
describe('LabelsSelect', () => { describe('LabelsSelect', () => {
describe('getLabelTemplate', () => { describe('getLabelTemplate', () => {
const label = mockLabels[0]; describe('when normal label is present', () => {
let $labelEl; const label = mockLabels[0];
let $labelEl;
beforeEach(() => {
$labelEl = $(
LabelsSelect.getLabelTemplate({
labels: mockLabels,
issueUpdateURL: mockUrl,
}),
);
});
it('generated label item template has correct label URL', () => { beforeEach(() => {
expect($labelEl.attr('href')).toBe('/foo/bar?label_name[]=Foo%20Label'); $labelEl = $(
}); LabelsSelect.getLabelTemplate({
labels: mockLabels,
issueUpdateURL: mockUrl,
enableScopedLabels: true,
scopedLabelsDocumentationLink: 'docs-link',
}),
);
});
it('generated label item template has correct label title', () => { it('generated label item template has correct label URL', () => {
expect($labelEl.find('span.label').text()).toBe(label.title); expect($labelEl.attr('href')).toBe('/foo/bar?label_name[]=Foo%20Label');
}); });
it('generated label item template has label description as title attribute', () => { it('generated label item template has correct label title', () => {
expect($labelEl.find('span.label').attr('title')).toBe(label.description); expect($labelEl.find('span.label').text()).toBe(label.title);
}); });
it('generated label item template has label description as title attribute', () => {
expect($labelEl.find('span.label').attr('title')).toBe(label.description);
});
it('generated label item template has correct label styles', () => { it('generated label item template has correct label styles', () => {
expect($labelEl.find('span.label').attr('style')).toBe( expect($labelEl.find('span.label').attr('style')).toBe(
`background-color: ${label.color}; color: ${label.text_color};`, `background-color: ${label.color}; color: ${label.text_color};`,
); );
});
it('generated label item has a badge class', () => {
expect($labelEl.find('span').hasClass('badge')).toEqual(true);
});
it('generated label item template does not have scoped-label class', () => {
expect($labelEl.find('.scoped-label')).toHaveLength(0);
});
}); });
it('generated label item has a badge class', () => { describe('when scoped label is present', () => {
expect($labelEl.find('span').hasClass('badge')).toEqual(true); const label = mockScopedLabels[0];
let $labelEl;
beforeEach(() => {
$labelEl = $(
LabelsSelect.getLabelTemplate({
labels: mockScopedLabels,
issueUpdateURL: mockUrl,
enableScopedLabels: true,
scopedLabelsDocumentationLink: 'docs-link',
}),
);
});
it('generated label item template has correct label URL', () => {
expect($labelEl.find('a').attr('href')).toBe('/foo/bar?label_name[]=Foo%3A%3ABar');
});
it('generated label item template has correct label title', () => {
expect($labelEl.find('span.label').text()).toBe(label.title);
});
it('generated label item template has html flag as true', () => {
expect($labelEl.find('span.label').attr('data-html')).toBe('true');
});
it('generated label item template has question icon', () => {
expect($labelEl.find('i.fa-question-circle')).toHaveLength(1);
});
it('generated label item template has scoped-label class', () => {
expect($labelEl.find('.scoped-label')).toHaveLength(1);
});
it('generated label item template has correct label styles', () => {
expect($labelEl.find('span.label').attr('style')).toBe(
`background-color: ${label.color}; color: ${label.text_color};`,
);
});
it('generated label item has a badge class', () => {
expect($labelEl.find('span').hasClass('badge')).toEqual(true);
});
}); });
}); });
}); });
...@@ -249,4 +249,24 @@ describe LabelsHelper do ...@@ -249,4 +249,24 @@ describe LabelsHelper do
.to match_array([label2, label4, label1, label3]) .to match_array([label2, label4, label1, label3])
end end
end end
describe 'label_from_hash' do
it 'builds a group label with whitelisted attributes' do
label = label_from_hash({ title: 'foo', color: 'bar', id: 1, group_id: 1 })
expect(label).to be_a(GroupLabel)
expect(label.id).to be_nil
expect(label.title).to eq('foo')
expect(label.color).to eq('bar')
end
it 'builds a project label with whitelisted attributes' do
label = label_from_hash({ title: 'foo', color: 'bar', id: 1, project_id: 1 })
expect(label).to be_a(ProjectLabel)
expect(label.id).to be_nil
expect(label.title).to eq('foo')
expect(label.color).to eq('bar')
end
end
end end
...@@ -178,6 +178,7 @@ describe('Issue model', () => { ...@@ -178,6 +178,7 @@ describe('Issue model', () => {
spyOn(Vue.http, 'patch').and.callFake((url, data) => { spyOn(Vue.http, 'patch').and.callFake((url, data) => {
expect(data.issue.assignee_ids).toEqual([1]); expect(data.issue.assignee_ids).toEqual([1]);
done(); done();
return Promise.resolve();
}); });
issue.update('url'); issue.update('url');
...@@ -187,6 +188,7 @@ describe('Issue model', () => { ...@@ -187,6 +188,7 @@ describe('Issue model', () => {
spyOn(Vue.http, 'patch').and.callFake((url, data) => { spyOn(Vue.http, 'patch').and.callFake((url, data) => {
expect(data.issue.assignee_ids).toEqual([0]); expect(data.issue.assignee_ids).toEqual([0]);
done(); done();
return Promise.resolve();
}); });
issue.removeAllAssignees(); issue.removeAllAssignees();
......
...@@ -45,12 +45,21 @@ describe('DropdownButtonComponent', () => { ...@@ -45,12 +45,21 @@ describe('DropdownButtonComponent', () => {
}); });
const vmMoreLabels = createComponent(mockMoreLabels); const vmMoreLabels = createComponent(mockMoreLabels);
expect(vmMoreLabels.dropdownToggleText).toBe('Foo Label +1 more'); expect(vmMoreLabels.dropdownToggleText).toBe(
`Foo Label +${mockMoreLabels.labels.length - 1} more`,
);
vmMoreLabels.$destroy(); vmMoreLabels.$destroy();
}); });
it('returns first label name when `labels` prop has only one item present', () => { it('returns first label name when `labels` prop has only one item present', () => {
expect(vm.dropdownToggleText).toBe('Foo Label'); const singleLabel = Object.assign({}, componentConfig, {
labels: [mockLabels[0]],
});
const vmSingleLabel = createComponent(singleLabel);
expect(vmSingleLabel.dropdownToggleText).toBe(mockLabels[0].title);
vmSingleLabel.$destroy();
}); });
}); });
}); });
...@@ -73,7 +82,7 @@ describe('DropdownButtonComponent', () => { ...@@ -73,7 +82,7 @@ describe('DropdownButtonComponent', () => {
const dropdownToggleTextEl = vm.$el.querySelector('.dropdown-toggle-text'); const dropdownToggleTextEl = vm.$el.querySelector('.dropdown-toggle-text');
expect(dropdownToggleTextEl).not.toBeNull(); expect(dropdownToggleTextEl).not.toBeNull();
expect(dropdownToggleTextEl.innerText.trim()).toBe('Foo Label'); expect(dropdownToggleTextEl.innerText.trim()).toBe('Foo Label +1 more');
}); });
it('renders dropdown button icon', () => { it('renders dropdown button icon', () => {
......
...@@ -35,9 +35,12 @@ describe('DropdownValueCollapsedComponent', () => { ...@@ -35,9 +35,12 @@ describe('DropdownValueCollapsedComponent', () => {
}); });
it('returns labels names separated by coma when `labels` prop has more than one item', () => { it('returns labels names separated by coma when `labels` prop has more than one item', () => {
const vmMoreLabels = createComponent(mockLabels.concat(mockLabels)); const labels = mockLabels.concat(mockLabels);
const vmMoreLabels = createComponent(labels);
expect(vmMoreLabels.labelsList).toBe('Foo Label, Foo Label'); const expectedText = labels.map(label => label.title).join(', ');
expect(vmMoreLabels.labelsList).toBe(expectedText);
vmMoreLabels.$destroy(); vmMoreLabels.$destroy();
}); });
...@@ -49,14 +52,19 @@ describe('DropdownValueCollapsedComponent', () => { ...@@ -49,14 +52,19 @@ describe('DropdownValueCollapsedComponent', () => {
const vmMoreLabels = createComponent(mockMoreLabels); const vmMoreLabels = createComponent(mockMoreLabels);
expect(vmMoreLabels.labelsList).toBe( const expectedText = `${mockMoreLabels
'Foo Label, Foo Label, Foo Label, Foo Label, Foo Label, and 2 more', .slice(0, 5)
); .map(label => label.title)
.join(', ')}, and ${mockMoreLabels.length - 5} more`;
expect(vmMoreLabels.labelsList).toBe(expectedText);
vmMoreLabels.$destroy(); vmMoreLabels.$destroy();
}); });
it('returns first label name when `labels` prop has only one item present', () => { it('returns first label name when `labels` prop has only one item present', () => {
expect(vm.labelsList).toBe('Foo Label'); const text = mockLabels.map(label => label.title).join(', ');
expect(vm.labelsList).toBe(text);
}); });
}); });
}); });
......
import Vue from 'vue'; import Vue from 'vue';
import $ from 'jquery';
import dropdownValueComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_value.vue'; import dropdownValueComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_value.vue';
...@@ -15,6 +16,7 @@ const createComponent = ( ...@@ -15,6 +16,7 @@ const createComponent = (
return mountComponent(Component, { return mountComponent(Component, {
labels, labels,
labelFilterBasePath, labelFilterBasePath,
enableScopedLabels: true,
}); });
}; };
...@@ -67,6 +69,26 @@ describe('DropdownValueComponent', () => { ...@@ -67,6 +69,26 @@ describe('DropdownValueComponent', () => {
expect(styleObj.backgroundColor).toBe(label.color); expect(styleObj.backgroundColor).toBe(label.color);
}); });
}); });
describe('scopedLabelsDescription', () => {
it('returns html for tooltip', () => {
const html = vm.scopedLabelsDescription(mockLabels[1]);
const $el = $.parseHTML(html);
expect($el[0]).toHaveClass('scoped-label-tooltip-title');
expect($el[2].textContent).toEqual(mockLabels[1].description);
});
});
describe('showScopedLabels', () => {
it('returns true if the label is scoped label', () => {
expect(vm.showScopedLabels(mockLabels[1])).toBe(true);
});
it('returns false when label is a regular label', () => {
expect(vm.showScopedLabels(mockLabels[0])).toBe(false);
});
});
}); });
describe('template', () => { describe('template', () => {
...@@ -91,15 +113,25 @@ describe('DropdownValueComponent', () => { ...@@ -91,15 +113,25 @@ describe('DropdownValueComponent', () => {
); );
}); });
it('renders label element with tooltip and styles based on label details', () => { it('renders label element and styles based on label details', () => {
const labelEl = vm.$el.querySelector('a span.badge.color-label'); const labelEl = vm.$el.querySelector('a span.badge.color-label');
expect(labelEl).not.toBeNull(); expect(labelEl).not.toBeNull();
expect(labelEl.dataset.placement).toBe('bottom');
expect(labelEl.dataset.container).toBe('body');
expect(labelEl.dataset.originalTitle).toBe(mockLabels[0].description);
expect(labelEl.getAttribute('style')).toBe('background-color: rgb(186, 218, 85);'); expect(labelEl.getAttribute('style')).toBe('background-color: rgb(186, 218, 85);');
expect(labelEl.innerText.trim()).toBe(mockLabels[0].title); expect(labelEl.innerText.trim()).toBe(mockLabels[0].title);
}); });
describe('label is of scoped-label type', () => {
it('renders a scoped-label-wrapper span to incorporate 2 anchors', () => {
expect(vm.$el.querySelector('span.scoped-label-wrapper')).not.toBeNull();
});
it('renders anchor tag containing question icon', () => {
const anchor = vm.$el.querySelector('.scoped-label-wrapper a.scoped-label');
expect(anchor).not.toBeNull();
expect(anchor.querySelector('i.fa-question-circle')).not.toBeNull();
});
});
}); });
}); });
...@@ -6,6 +6,13 @@ export const mockLabels = [ ...@@ -6,6 +6,13 @@ export const mockLabels = [
color: '#BADA55', color: '#BADA55',
text_color: '#FFFFFF', text_color: '#FFFFFF',
}, },
{
id: 27,
title: 'Foo::Bar',
description: 'Foobar',
color: '#0033CC',
text_color: '#FFFFFF',
},
]; ];
export const mockSuggestedColors = [ export const mockSuggestedColors = [
......
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