[frontend] backport of scoped labels

Scoped labels in EE require additional changes in CE code.
This commit is contained in:
Rajat Jain 2019-04-02 12:46:21 +02:00 committed by Jan Provaznik
parent d0a0d3d3d5
commit 97ab853996
20 changed files with 466 additions and 64 deletions

View file

@ -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;
}
});
}
}

View file

@ -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 = {}) {
@ -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) {
@ -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>';
}
@ -358,6 +400,7 @@ export default class LabelsSelect {
} else {
if (!$dropdown.hasClass('js-filter-bulk-update')) {
saveLabelData();
$dropdown.data('glDropdown').clearMenu();
}
}
}
@ -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() {

View file

@ -1,3 +1,3 @@
import Labels from '~/labels';
import Labels from 'ee_else_ce/labels';
document.addEventListener('DOMContentLoaded', () => new Labels());

View file

@ -1,3 +1,3 @@
import Labels from '~/labels';
import Labels from 'ee_else_ce/labels';
document.addEventListener('DOMContentLoaded', () => new Labels());

View file

@ -1,3 +1,3 @@
import Labels from '~/labels';
import Labels from 'ee_else_ce/labels';
document.addEventListener('DOMContentLoaded', () => new Labels());

View file

@ -1,3 +1,3 @@
import Labels from '~/labels';
import Labels from 'ee_else_ce/labels';
document.addEventListener('DOMContentLoaded', () => new Labels());

View file

@ -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() {
@ -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;">
@ -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

View file

@ -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() {
@ -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"

View file

@ -1,9 +1,11 @@
<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: {
@ -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() {
@ -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>
@ -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>

View file

@ -0,0 +1,35 @@
<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>

View file

@ -0,0 +1,47 @@
<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>

View file

@ -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 {

View file

@ -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;
}
}
}

View file

@ -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 ""

View file

@ -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;
describe('when normal label is present', () => {
const label = mockLabels[0];
let $labelEl;
beforeEach(() => {
$labelEl = $(
LabelsSelect.getLabelTemplate({
labels: mockLabels,
issueUpdateURL: mockUrl,
}),
);
beforeEach(() => {
$labelEl = $(
LabelsSelect.getLabelTemplate({
labels: mockLabels,
issueUpdateURL: mockUrl,
enableScopedLabels: true,
scopedLabelsDocumentationLink: 'docs-link',
}),
);
});
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 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 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 template has correct label URL', () => {
expect($labelEl.attr('href')).toBe('/foo/bar?label_name[]=Foo%20Label');
});
describe('when scoped label is present', () => {
const label = mockScopedLabels[0];
let $labelEl;
it('generated label item template has correct label title', () => {
expect($labelEl.find('span.label').text()).toBe(label.title);
});
beforeEach(() => {
$labelEl = $(
LabelsSelect.getLabelTemplate({
labels: mockScopedLabels,
issueUpdateURL: mockUrl,
enableScopedLabels: true,
scopedLabelsDocumentationLink: 'docs-link',
}),
);
});
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 URL', () => {
expect($labelEl.find('a').attr('href')).toBe('/foo/bar?label_name[]=Foo%3A%3ABar');
});
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 title', () => {
expect($labelEl.find('span.label').text()).toBe(label.title);
});
it('generated label item has a badge class', () => {
expect($labelEl.find('span').hasClass('badge')).toEqual(true);
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);
});
});
});
});

View file

@ -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');
@ -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();

View file

@ -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();
});
});
});
@ -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', () => {

View file

@ -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();
});
@ -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);
});
});
});

View file

@ -1,4 +1,5 @@
import Vue from 'vue';
import $ from 'jquery';
import dropdownValueComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_value.vue';
@ -15,6 +16,7 @@ const createComponent = (
return mountComponent(Component, {
labels,
labelFilterBasePath,
enableScopedLabels: true,
});
};
@ -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', () => {
@ -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();
});
});
});
});

View file

@ -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 = [