Skip to content
Snippets Groups Projects
Commit 97ab8539 authored by Rajat Jain's avatar Rajat Jain Committed by Jan Provaznik
Browse files

[frontend] backport of scoped labels

Scoped labels in EE require additional changes in CE code.
parent d0a0d3d3
No related branches found
No related tags found
No related merge requests found
Showing
with 467 additions and 65 deletions
Loading
Loading
@@ -119,7 +119,17 @@ class ListIssue {
}
 
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;
}
});
}
}
 
Loading
Loading
Loading
Loading
@@ -11,6 +11,7 @@ import CreateLabelDropdown from './create_label';
import flash from './flash';
import ModalStore from './boards/stores/modal_store';
import boardsStore from './boards/stores/boards_store';
import { isEE } from '~/lib/utils/common_utils';
 
export default class LabelsSelect {
constructor(els, options = {}) {
Loading
Loading
@@ -86,8 +87,9 @@ export default class LabelsSelect {
return this.value;
})
.get();
const scopedLabels = $dropdown.data('scopedLabels');
const scopedLabelsDocumentationLink = $dropdown.data('scopedLabelsDocumentationLink');
const { handleClick } = options;
$sidebarLabelTooltip.tooltip();
 
if ($dropdown.closest('.dropdown').find('.dropdown-new-label').length) {
Loading
Loading
@@ -132,8 +134,48 @@ export default class LabelsSelect {
template = LabelsSelect.getLabelTemplate({
labels: data.labels,
issueUpdateURL,
enableScopedLabels: scopedLabels,
scopedLabelsDocumentationLink,
});
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 + "']")
.remove();
});
}
} else {
template = '<span class="no-value">None</span>';
}
Loading
Loading
@@ -358,6 +400,7 @@ export default class LabelsSelect {
} else {
if (!$dropdown.hasClass('js-filter-bulk-update')) {
saveLabelData();
$dropdown.data('glDropdown').clearMenu();
}
}
}
Loading
Loading
@@ -471,19 +514,61 @@ export default class LabelsSelect {
// so best approach is to use traditional way of
// concatenation
// see: http://2ality.com/2016/05/template-literal-whitespace.html#joining-arrays
const tpl = _.template(
const labelTemplate = _.template(
[
'<% _.each(labels, function(label){ %>',
'<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 }) %>" style="background-color: <%- label.color %>; color: <%- label.text_color %>;">',
'<%- label.title %>',
'</span>',
'</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: <%- label.color %>; color: <%- 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 />',
'<%- label.description %>',
'<% } else { %>',
'<%- 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, linkAttrs: \'data-html="true"\' }) %>',
'<%= infoIconTemplate({ label,scopedLabelsDocumentationLink }) %>',
'</span>',
'<% } else { %>',
'<%= labelTemplate({ label, issueUpdateURL, isScopedLabel, enableScopedLabels, tooltipTitleTemplate, linkAttrs: "" }) %>',
'<% } %>',
'<% }); %>',
].join(''),
);
 
return tpl(tplData);
return tpl({
...tplData,
labelTemplate,
infoIconTemplate,
tooltipTitleTemplate,
isScopedLabel,
});
}
 
bindEvents() {
Loading
Loading
import Labels from '~/labels';
import Labels from 'ee_else_ce/labels';
 
document.addEventListener('DOMContentLoaded', () => new Labels());
import Labels from '~/labels';
import Labels from 'ee_else_ce/labels';
 
document.addEventListener('DOMContentLoaded', () => new Labels());
import Labels from '~/labels';
import Labels from 'ee_else_ce/labels';
 
document.addEventListener('DOMContentLoaded', () => new Labels());
import Labels from '~/labels';
import Labels from 'ee_else_ce/labels';
 
document.addEventListener('DOMContentLoaded', () => new Labels());
Loading
Loading
@@ -75,6 +75,16 @@ export default {
required: false,
default: false,
},
enableScopedLabels: {
type: Boolean,
require: false,
default: false,
},
scopedLabelsDocumentationLink: {
type: String,
require: false,
default: '#',
},
},
computed: {
hiddenInputName() {
Loading
Loading
@@ -123,7 +133,12 @@ export default {
@onValueClick="handleCollapsedValueClick"
/>
<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>
</dropdown-value>
<div v-if="canEdit" class="selectbox js-selectbox" style="display: none;">
Loading
Loading
@@ -142,6 +157,8 @@ export default {
:namespace="namespace"
:labels="context.labels"
:show-extra-options="!showCreate"
:scoped-labels-documentation-link="scopedLabelsDocumentationLink"
:enable-scoped-labels="enableScopedLabels"
/>
<div
class="dropdown-menu dropdown-select dropdown-menu-paging
Loading
Loading
Loading
Loading
@@ -31,6 +31,16 @@ export default {
type: Boolean,
required: true,
},
enableScopedLabels: {
type: Boolean,
require: false,
default: false,
},
scopedLabelsDocumentationLink: {
type: String,
require: false,
default: '#',
},
},
computed: {
dropdownToggleText() {
Loading
Loading
@@ -61,6 +71,8 @@ export default {
:data-labels="labelsPath"
:data-namespace-path="namespace"
:data-show-any="showExtraOptions"
:data-scoped-labels="enableScopedLabels"
:data-scoped-labels-documentation-link="scopedLabelsDocumentationLink"
type="button"
class="dropdown-menu-toggle wide js-label-select js-multiselect js-context-config-modal"
data-toggle="dropdown"
Loading
Loading
<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 {
directives: {
tooltip,
components: {
DropdownValueScopedLabel,
DropdownValueRegularLabel,
},
props: {
labels: {
Loading
Loading
@@ -14,6 +16,16 @@ export default {
type: String,
required: true,
},
enableScopedLabels: {
type: Boolean,
required: false,
default: false,
},
scopedLabelsDocumentationLink: {
type: String,
required: false,
default: '#',
},
},
computed: {
isEmpty() {
Loading
Loading
@@ -30,6 +42,12 @@ export default {
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>
Loading
Loading
@@ -44,17 +62,24 @@ export default {
<span v-if="isEmpty" class="text-secondary">
<slot>{{ __('None') }}</slot>
</span>
<a v-for="label in labels" v-else :key="label.id" :href="labelFilterUrl(label)">
<span
v-tooltip
:style="labelStyle(label)"
:title="label.description"
class="badge color-label"
data-placement="bottom"
data-container="body"
>
{{ label.title }}
</span>
</a>
<template v-for="label in labels" v-else>
<dropdown-value-scoped-label
v-if="showScopedLabels(label)"
:key="label.id"
:label="label"
:label-filter-url="labelFilterUrl(label)"
:label-style="labelStyle(label)"
:scoped-labels-documentation-link="scopedLabelsDocumentationLink"
/>
<dropdown-value-regular-label
v-else
:key="label.id"
:label="label"
:label-filter-url="labelFilterUrl(label)"
:label-style="labelStyle(label)"
/>
</template>
</div>
</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>
Loading
Loading
@@ -110,6 +110,16 @@
font-size: 0;
margin-bottom: -5px;
}
.scoped-label-wrapper {
.color-label {
padding-right: $gl-padding-24;
}
.scoped-label {
right: 12px;
}
}
}
 
.right-sidebar {
Loading
Loading
Loading
Loading
@@ -402,3 +402,39 @@
.priority-labels-empty-state .svg-content img {
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;
}
}
}
Loading
Loading
@@ -6974,6 +6974,9 @@ msgstr ""
msgid "Scope not supported with disabled 'users_search' feature!"
msgstr ""
 
msgid "Scoped label"
msgstr ""
msgid "Scroll down to <strong>Google Code Project Hosting</strong> and enable the switch on the right."
msgstr ""
 
Loading
Loading
Loading
Loading
@@ -13,40 +13,104 @@ const mockLabels = [
},
];
 
const mockScopedLabels = [
{
id: 27,
title: 'Foo::Bar',
description: 'Foobar',
color: '#333ABC',
text_color: '#FFFFFF',
},
];
describe('LabelsSelect', () => {
describe('getLabelTemplate', () => {
const label = mockLabels[0];
let $labelEl;
beforeEach(() => {
$labelEl = $(
LabelsSelect.getLabelTemplate({
labels: mockLabels,
issueUpdateURL: mockUrl,
}),
);
});
describe('when normal label is present', () => {
const label = mockLabels[0];
let $labelEl;
 
it('generated label item template has correct label URL', () => {
expect($labelEl.attr('href')).toBe('/foo/bar?label_name[]=Foo%20Label');
});
beforeEach(() => {
$labelEl = $(
LabelsSelect.getLabelTemplate({
labels: mockLabels,
issueUpdateURL: mockUrl,
enableScopedLabels: true,
scopedLabelsDocumentationLink: 'docs-link',
}),
);
});
 
it('generated label item template has correct label title', () => {
expect($labelEl.find('span.label').text()).toBe(label.title);
});
it('generated label item template has correct label URL', () => {
expect($labelEl.attr('href')).toBe('/foo/bar?label_name[]=Foo%20Label');
});
 
it('generated label item template has label description as title attribute', () => {
expect($labelEl.find('span.label').attr('title')).toBe(label.description);
});
it('generated label item template has correct label title', () => {
expect($labelEl.find('span.label').text()).toBe(label.title);
});
it('generated label item template has label description as title attribute', () => {
expect($labelEl.find('span.label').attr('title')).toBe(label.description);
});
 
it('generated label item template has correct label styles', () => {
expect($labelEl.find('span.label').attr('style')).toBe(
`background-color: ${label.color}; color: ${label.text_color};`,
);
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);
});
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', () => {
expect($labelEl.find('span').hasClass('badge')).toEqual(true);
describe('when scoped label is present', () => {
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);
});
});
});
});
Loading
Loading
@@ -178,6 +178,7 @@ describe('Issue model', () => {
spyOn(Vue.http, 'patch').and.callFake((url, data) => {
expect(data.issue.assignee_ids).toEqual([1]);
done();
return Promise.resolve();
});
 
issue.update('url');
Loading
Loading
@@ -187,6 +188,7 @@ describe('Issue model', () => {
spyOn(Vue.http, 'patch').and.callFake((url, data) => {
expect(data.issue.assignee_ids).toEqual([0]);
done();
return Promise.resolve();
});
 
issue.removeAllAssignees();
Loading
Loading
Loading
Loading
@@ -45,12 +45,21 @@ describe('DropdownButtonComponent', () => {
});
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();
});
 
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();
});
});
});
Loading
Loading
@@ -73,7 +82,7 @@ describe('DropdownButtonComponent', () => {
const dropdownToggleTextEl = vm.$el.querySelector('.dropdown-toggle-text');
 
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', () => {
Loading
Loading
Loading
Loading
@@ -35,9 +35,12 @@ describe('DropdownValueCollapsedComponent', () => {
});
 
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();
});
 
Loading
Loading
@@ -49,14 +52,19 @@ describe('DropdownValueCollapsedComponent', () => {
 
const vmMoreLabels = createComponent(mockMoreLabels);
 
expect(vmMoreLabels.labelsList).toBe(
'Foo Label, Foo Label, Foo Label, Foo Label, Foo Label, and 2 more',
);
const expectedText = `${mockMoreLabels
.slice(0, 5)
.map(label => label.title)
.join(', ')}, and ${mockMoreLabels.length - 5} more`;
expect(vmMoreLabels.labelsList).toBe(expectedText);
vmMoreLabels.$destroy();
});
 
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);
});
});
});
Loading
Loading
import Vue from 'vue';
import $ from 'jquery';
 
import dropdownValueComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_value.vue';
 
Loading
Loading
@@ -15,6 +16,7 @@ const createComponent = (
return mountComponent(Component, {
labels,
labelFilterBasePath,
enableScopedLabels: true,
});
};
 
Loading
Loading
@@ -67,6 +69,26 @@ describe('DropdownValueComponent', () => {
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', () => {
Loading
Loading
@@ -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');
 
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.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();
});
});
});
});
Loading
Loading
@@ -6,6 +6,13 @@ export const mockLabels = [
color: '#BADA55',
text_color: '#FFFFFF',
},
{
id: 27,
title: 'Foo::Bar',
description: 'Foobar',
color: '#0033CC',
text_color: '#FFFFFF',
},
];
 
export const mockSuggestedColors = [
Loading
Loading
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment