Commit ae5ecfad authored by Filipa Lacerda's avatar Filipa Lacerda

Merge branch '3727-fe-labels-for-epics' into 'master'

Add labels support to Epics

Closes #3727 and #4032

See merge request gitlab-org/gitlab-ee!4773
parents c0c34cda 9a058ba9
......@@ -7,6 +7,7 @@ export default class FilteredSearchBoards extends FilteredSearchManager {
constructor(store, updateUrl = false, cantEdit = []) {
super({
page: 'boards',
isGroup: true,
filteredSearchTokenKeys: FilteredSearchTokenKeysIssues,
stateFiltersSelector: '.issues-state-filters',
});
......
......@@ -125,6 +125,16 @@ export default class FilteredSearchDropdownManager {
endpoint = `${endpoint}?only_group_labels=true`;
}
// EE-only
if (this.groupAncestor) {
endpoint = `${endpoint}&include_ancestor_groups=true`;
}
// EE-only
if (this.isGroupDecendent) {
endpoint = `${endpoint}&include_descendant_groups=true`;
}
return endpoint;
}
......
......@@ -109,6 +109,7 @@ export default class FilteredSearchManager {
page: this.page,
isGroup: this.isGroup,
isGroupAncestor: this.isGroupAncestor,
isGroupDecendent: this.isGroupDecendent,
filteredSearchTokenKeys: this.filteredSearchTokenKeys,
});
......
......@@ -88,7 +88,7 @@ export default {
</script>
<template>
<div class="block labels">
<div class="block labels js-labels-block">
<dropdown-value-collapsed
v-if="showCreate"
:labels="context.labels"
......@@ -104,7 +104,7 @@ export default {
</dropdown-value>
<div
v-if="canEdit"
class="selectbox"
class="selectbox js-selectbox"
style="display: none;"
>
<dropdown-hidden-input
......
......@@ -35,7 +35,7 @@ export default {
</script>
<template>
<div class="hide-collapsed value issuable-show-labels">
<div class="hide-collapsed value issuable-show-labels js-value">
<span
v-if="isEmpty"
class="text-secondary"
......
- page_title 'Labels'
- issuables = ['issues', 'merge requests'] + (@group&.feature_available?(:epics) ? ['epics'] : [])
.top-area.adjust
.nav-text
Labels can be applied to issues and merge requests. Group labels are available for any project within the group.
= _("Labels can be applied to %{features}. Group labels are available for any project within the group.") % { features: issuables.to_sentence }
.nav-controls
- if can?(current_user, :admin_label, @group)
......@@ -16,4 +18,4 @@
= paginate @labels, theme: 'gitlab'
- else
.nothing-here-block
No labels created yet.
= _("No labels created yet.")
......@@ -2,6 +2,7 @@
- status = label_subscription_status(label, @project).inquiry if current_user
- subject = local_assigns[:subject]
- toggle_subscription_path = toggle_subscription_label_path(label, @project) if current_user
- show_label_epics_link = @group&.feature_available?(:epics)
- show_label_merge_requests_link = show_label_issuables_link?(label, :merge_requests, project: @project)
- show_label_issues_link = show_label_issuables_link?(label, :issues, project: @project)
......@@ -14,6 +15,10 @@
= icon('caret-down')
.dropdown-menu.dropdown-menu-align-right
%ul
- if show_label_epics_link
%li
= link_to group_epics_path(@group, label_name:[label.name]) do
View epics
- if show_label_merge_requests_link
%li
= link_to_label(label, subject: subject, type: :merge_request) do
......
- subject = local_assigns[:subject]
- show_label_epics_link = @group&.feature_available?(:epics)
- show_label_issues_link = show_label_issuables_link?(label, :issues, project: @project)
- show_label_merge_requests_link = show_label_issuables_link?(label, :merge_requests, project: @project)
......@@ -23,6 +24,9 @@
.description-text
= markdown_field(label, :description)
.hidden-xs.hidden-sm
- if show_label_epics_link
= link_to 'Epics', group_epics_path(@group, label_name:[label.name])
&middot;
- if show_label_issues_link
= link_to_label(label, subject: subject) { 'Issues' }
- if show_label_merge_requests_link
......
......@@ -210,4 +210,3 @@ DELETE /groups/:id/-/epics/:epic_iid
```bash
curl --header DELETE "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/1/-/epics/5?title=New%20Title
```
......@@ -3,8 +3,9 @@
import issuableApp from '~/issue_show/components/app.vue';
import relatedIssuesRoot from 'ee/related_issues/components/related_issues_root.vue';
import issuableAppEventHub from '~/issue_show/event_hub';
import epicHeader from './epic_header.vue';
import epicSidebar from '../../sidebar/components/sidebar_app.vue';
import SidebarContext from '../sidebar_context';
import epicHeader from './epic_header.vue';
export default {
name: 'EpicShowApp',
......@@ -85,6 +86,27 @@
type: String,
required: false,
},
labels: {
type: Array,
required: true,
},
namespace: {
type: String,
required: false,
default: '#',
},
labelsPath: {
type: String,
required: true,
},
labelsWebUrl: {
type: String,
required: true,
},
epicsWebUrl: {
type: String,
required: true,
},
},
data() {
return {
......@@ -94,6 +116,9 @@
projectNamespace: '',
};
},
mounted() {
this.sidebarContext = new SidebarContext();
},
methods: {
deleteEpic() {
issuableAppEventHub.$emit('delete.issuable');
......@@ -137,6 +162,12 @@
:editable="canUpdate"
:initial-start-date="startDate"
:initial-end-date="endDate"
:initial-labels="labels"
:namespace="namespace"
:update-path="updateEndpoint"
:labels-path="labelsPath"
:labels-web-url="labelsWebUrl"
:epics-web-url="epicsWebUrl"
/>
<related-issues-root
:endpoint="issueLinksEndpoint"
......
import Vue from 'vue';
import '~/vue_shared/models/label';
import EpicShowApp from './components/epic_show_app.vue';
export default () => {
......@@ -6,7 +7,7 @@ export default () => {
const metaData = JSON.parse(el.dataset.meta);
const initialData = JSON.parse(el.dataset.initial);
const props = Object.assign({}, initialData, metaData);
const props = Object.assign({}, initialData, metaData, el.dataset);
// Convert backend casing to match frontend style guide
props.startDate = props.start_date;
......
import Mousetrap from 'mousetrap';
export default class SidebarContext {
constructor() {
const $issuableSidebar = $('.js-issuable-update');
Mousetrap.bind('l', () => SidebarContext.openSidebarDropdown($issuableSidebar.find('.js-labels-block')));
$issuableSidebar
.off('click', '.js-sidebar-dropdown-toggle')
.on('click', '.js-sidebar-dropdown-toggle', function onClickEdit(e) {
e.preventDefault();
const $block = $(this).parents('.js-labels-block');
const $selectbox = $block.find('.js-selectbox');
// We use `:visible` to detect element visibility
// since labels dropdown itself is handled by
// labels_select.js which internally uses
// $.hide() & $.show() to toggle elements
// which requires us to use `display: none;`
// in `labels_select/base.vue` as well.
// see: https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/4773#note_61844731
if ($selectbox.is(':visible')) {
$selectbox.hide();
$block.find('.js-value').show();
} else {
$selectbox.show();
$block.find('.js-value').hide();
}
if ($selectbox.is(':visible')) {
setTimeout(() => $block.find('.js-label-select').trigger('click'), 0);
}
});
}
static openSidebarDropdown($block) {
$block.find('.js-sidebar-dropdown-toggle').trigger('click');
}
}
<script>
/* global ListLabel */
/* eslint-disable vue/require-default-prop */
import Cookies from 'js-cookie';
import Flash from '~/flash';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import sidebarDatePicker from '~/vue_shared/components/sidebar/date_picker.vue';
import sidebarCollapsedGroupedDatePicker from '~/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue';
import SidebarDatePicker from '~/vue_shared/components/sidebar/date_picker.vue';
import SidebarCollapsedGroupedDatePicker from '~/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue';
import SidebarLabelsSelect from '~/vue_shared/components/sidebar/labels_select/base.vue';
import SidebarService from '../services/sidebar_service';
import Store from '../stores/sidebar_store';
export default {
name: 'EpicSidebar',
components: {
sidebarDatePicker,
sidebarCollapsedGroupedDatePicker,
SidebarDatePicker,
SidebarCollapsedGroupedDatePicker,
SidebarLabelsSelect,
},
props: {
endpoint: {
......@@ -32,6 +35,31 @@
type: String,
required: false,
},
initialLabels: {
type: Array,
required: true,
},
namespace: {
type: String,
required: false,
default: '#',
},
updatePath: {
type: String,
required: true,
},
labelsPath: {
type: String,
required: true,
},
labelsWebUrl: {
type: String,
required: true,
},
epicsWebUrl: {
type: String,
required: true,
},
},
data() {
const store = new Store({
......@@ -46,13 +74,16 @@
savingStartDate: false,
savingEndDate: false,
service: new SidebarService(this.endpoint),
epicContext: {
labels: this.initialLabels,
},
};
},
methods: {
toggleSidebar() {
this.collapsed = !this.collapsed;
const contentContainer = this.$el.closest('.page-with-sidebar');
const contentContainer = this.$el.closest('.page-with-contextual-sidebar');
contentContainer.classList.toggle('right-sidebar-expanded');
contentContainer.classList.toggle('right-sidebar-collapsed');
......@@ -82,6 +113,24 @@
saveEndDate(date) {
return this.saveDate('end', date);
},
handleLabelClick(label) {
if (label.isAny) {
this.epicContext.labels = [];
} else {
const labelIndex = this.epicContext.labels.findIndex(l => l.id === label.id);
if (labelIndex === -1) {
this.epicContext.labels.push(new ListLabel({
id: label.id,
title: label.title,
color: label.color[0],
textColor: label.text_color,
}));
} else {
this.epicContext.labels.splice(labelIndex, 1);
}
}
},
},
};
</script>
......@@ -91,9 +140,10 @@
class="right-sidebar"
:class="{ 'right-sidebar-expanded' : !collapsed, 'right-sidebar-collapsed': collapsed }"
>
<div class="issuable-sidebar">
<div class="issuable-sidebar js-issuable-update">
<sidebar-date-picker
v-if="!collapsed"
block-class="start-date"
:collapsed="collapsed"
:is-loading="savingStartDate"
:editable="editable"
......@@ -106,6 +156,7 @@
/>
<sidebar-date-picker
v-if="!collapsed"
block-class="end-date"
:collapsed="collapsed"
:is-loading="savingEndDate"
:editable="editable"
......@@ -123,6 +174,20 @@
:show-toggle-sidebar="true"
@toggleCollapse="toggleSidebar"
/>
<sidebar-labels-select
ability-name="epic"
:context="epicContext"
:namespace="namespace"
:update-path="updatePath"
:labels-path="labelsPath"
:labels-web-url="labelsWebUrl"
:label-filter-base-path="epicsWebUrl"
:can-edit="editable"
:show-create="true"
@onLabelClick="handleLabelClick"
>
{{ __('None') }}
</sidebar-labels-select>
</div>
</aside>
</template>
......@@ -5,6 +5,13 @@ const tokenKeys = [{
symbol: '@',
icon: 'pencil',
tag: '@author',
}, {
key: 'label',
type: 'array',
param: 'name[]',
symbol: '~',
icon: 'tag',
tag: '~label',
}];
const alternativeTokenKeys = [{
......
......@@ -5,6 +5,9 @@ import initNewEpic from 'ee/epics/new_epic/new_epic_bundle';
document.addEventListener('DOMContentLoaded', () => {
initFilteredSearch({
page: 'epics',
isGroup: true,
isGroupAncestor: true,
isGroupDecendent: true,
filteredSearchTokenKeys: FilteredSearchTokenKeysEpics,
stateFiltersSelector: '.epics-state-filters',
});
......
......@@ -17,7 +17,13 @@ class EpicsFinder < IssuableFinder
end
def row_count
execute.count
count = execute.count
# When filtering by multiple labels, count returns a hash of
# records grouped by id - so we just have to get length of the Hash.
# Once we have state for epics, we can use default issuables row_count
# method.
count.is_a?(Hash) ? count.length : count
end
# we don't have states for epics for now this method (#4017)
......
module EpicsHelper
def epic_meta_data
author = @epic.author
def epic_show_app_data(epic, opts)
author = epic.author
group = epic.group
data = {
created: @epic.created_at,
epic_meta = {
created: epic.created_at,
author: {
name: author.name,
url: user_path(author),
username: "@#{author.username}",
src: avatar_icon_for_user(@epic.author)
src: opts[:author_icon]
},
start_date: @epic.start_date,
end_date: @epic.end_date
start_date: epic.start_date,
end_date: epic.end_date
}
data.to_json
{
initial: opts[:initial].merge(labels: epic.labels).to_json,
meta: epic_meta.to_json,
namespace: group.path,
labels_path: group_labels_path(group, format: :json, only_group_labels: true, include_ancestor_groups: true),
labels_web_url: group_labels_path(group),
epics_web_url: group_epics_path(group)
}
end
def epic_endpoint_query_params(opts)
opts[:data] ||= {}
opts[:data][:endpoint_query_params] = {
only_group_labels: true,
include_ancestor_groups: true,
include_descendant_groups: true
}.to_json
opts
end
end
......@@ -12,3 +12,7 @@
&middot;
opened #{time_ago_with_tooltip(epic.created_at, placement: 'bottom')}
by #{link_to_member(@group, epic.author, avatar: false)}
- if epic.labels.any?
&nbsp;
- epic.labels.each do |label|
= link_to render_colored_label(label, tooltip: true), group_epics_path(@group, label_name:[label.name]), class: 'label-link'
......@@ -15,4 +15,4 @@
- content_for :page_specific_javascripts do
= webpack_bundle_tag 'common_vue'
#epic-show-app{ data: { initial: issuable_initial_data(@epic).to_json, meta: epic_meta_data } }
#epic-show-app{ data: epic_show_app_data(@epic, author_icon: avatar_icon_for_user(@epic.author), initial: issuable_initial_data(@epic)) }
......@@ -19,7 +19,7 @@
.scroll-container
%ul.tokens-container.list-unstyled
%li.input-token
%input.form-control.filtered-search{ search_filter_input_options(type) }
%input.form-control.filtered-search{ epic_endpoint_query_params(search_filter_input_options(type)) }
#js-dropdown-hint.filtered-search-input-dropdown-menu.dropdown-menu.hint-dropdown
%ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { action: 'submit' } }
......@@ -46,6 +46,18 @@
= render 'shared/issuable/user_dropdown_item',
user: User.new(username: '{{username}}', name: '{{name}}'),
avatar: { lazy: true, url: '{{avatar_url}}' }
#js-dropdown-label.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { value: 'none' } }
%button.btn.btn-link{ type: 'button' }
= _("No Label")
%li.divider.droplab-item-ignore
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item{ type: 'button' }
%button.btn.btn-link
%span.dropdown-label-box{ style: 'background: {{color}}' }
%span.label-title.js-data-value
{{ title }}
%button.clear-search.hidden{ type: 'button' }
= icon('times')
......
---
title: Allow to add or remove labels from epics and filter epics by labels
merge_request: 4773
author:
type: added
---
title: Allow adding or removing labels from epics and filter epics by labels
merge_request:
author:
type: added
......@@ -130,4 +130,22 @@ describe EpicsFinder do
end
end
end
describe '#row_count' do
let(:label) { create(:label) }
let(:label2) { create(:label) }
let!(:labeled_epic) { create(:labeled_epic, group: group, labels: [label]) }
let!(:labeled_epic2) { create(:labeled_epic, group: group, labels: [label, label2]) }
before do
group.add_developer(search_user)
stub_licensed_features(epics: true)
end
it 'returns number of rows when epics are grouped' do
params = { group_id: group.id, label_name: [label.title, label2.title] }
expect(described_class.new(search_user, params).row_count).to eq(1)
end
end
end
......@@ -3,18 +3,32 @@ require 'spec_helper'
describe EpicsHelper do
include ApplicationHelper
describe '#epic_meta_data' do
describe '#epic_show_app_data' do
it 'returns the correct json' do
user = create(:user)
@epic = create(:epic, author: user)
expect(JSON.parse(epic_meta_data).keys).to match_array(%w[created author start_date end_date])
expect(JSON.parse(epic_meta_data)['author']).to eq({
data = epic_show_app_data(@epic, initial: {}, author_icon: 'icon_path')
meta_data = JSON.parse(data[:meta])
expected_keys = %i(initial meta namespace labels_path labels_web_url epics_web_url)
expect(data.keys).to match_array(expected_keys)
expect(meta_data.keys).to match_array(%w[created author start_date end_date])
expect(meta_data['author']).to eq({
'name' => user.name,
'url' => "/#{user.username}",
'username' => "@#{user.username}",
'src' => "#{avatar_icon_for_user(user)}"
'src' => 'icon_path'
})
end
end
describe '#epic_endpoint_query_params' do
it 'it includes epic specific options in JSON format' do
opts = epic_endpoint_query_params({})
expected = "{\"only_group_labels\":true,\"include_ancestor_groups\":true,\"include_descendant_groups\":true}"
expect(opts[:data][:endpoint_query_params]).to eq(expected)
end
end
end
......@@ -36,6 +36,11 @@ describe('EpicShowApp', () => {
markdownDocsPath,
author,
created,
namespace,
labelsPath,
labelsWebUrl,
epicsWebUrl,
labels,
} = props;
const EpicShowApp = Vue.extend(epicShowApp);
......@@ -72,6 +77,12 @@ describe('EpicShowApp', () => {
editable: canUpdate,
initialStartDate: startDate,
initialEndDate: endDate,
initialLabels: labels,
updatePath: updateEndpoint,
labelsPath,
labelsWebUrl,
epicsWebUrl,
namespace,
});
setTimeout(done);
......
export const mockLabels = [
{
id: 26,
title: 'Foo Label',
description: 'Foobar',
color: '#BADA55',
text_color: '#FFFFFF',
},
];
export const contentProps = {
endpoint: '',
updateEndpoint: gl.TEST_HOST,
......@@ -8,10 +18,15 @@ export const contentProps = {
markdownDocsPath: '',
issueLinksEndpoint: '/',
groupPath: '',
namespace: 'gitlab-org',
labelsPath: '',
labelsWebUrl: '',
epicsWebUrl: '',
initialTitleHtml: '',
initialTitleText: '',
startDate: '2017-01-01',
endDate: '2017-10-10',
labels: mockLabels,
};
export const headerProps = {
......
......@@ -3,15 +3,23 @@ import _ from 'underscore';
import Cookies from 'js-cookie';
import epicSidebar from 'ee/epics/sidebar/components/sidebar_app.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { props } from '../../epic_show/mock_data';
describe('epicSidebar', () => {
let vm;
let originalCookieState;
let EpicSidebar;
const {
updateEndpoint,
labelsPath,
labelsWebUrl,
epicsWebUrl,
labels,
} = props;
beforeEach(() => {
setFixtures(`
<div class="page-with-sidebar right-sidebar-expanded">
<div class="page-with-contextual-sidebar right-sidebar-expanded">
<div id="epic-sidebar"></div>
</div>
`);
......@@ -21,6 +29,11 @@ describe('epicSidebar', () => {
EpicSidebar = Vue.extend(epicSidebar);
vm = mountComponent(EpicSidebar, {
endpoint: gl.TEST_HOST,
initialLabels: labels,
updatePath: updateEndpoint,
labelsPath,
labelsWebUrl,
epicsWebUrl,
}, '#epic-sidebar');
});
......@@ -36,6 +49,11 @@ describe('epicSidebar', () => {
vm = mountComponent(EpicSidebar, {
endpoint: gl.TEST_HOST,
initialStartDate: '2017-01-01',
initialLabels: labels,
updatePath: updateEndpoint,
labelsPath,
labelsWebUrl,
epicsWebUrl,
});
expect(vm.$el.querySelector('.value-content strong').innerText.trim()).toEqual('Jan 1, 2017');
......@@ -45,6 +63,11 @@ describe('epicSidebar', () => {
vm = mountComponent(EpicSidebar, {
endpoint: gl.TEST_HOST,
initialEndDate: '2018-01-01',
initialLabels: labels,
updatePath: updateEndpoint,
labelsPath,
labelsWebUrl,
epicsWebUrl,
});
expect(vm.$el.querySelector('.value-content strong').innerText.trim()).toEqual('Jan 1, 2018');
......@@ -55,6 +78,11 @@ describe('epicSidebar', () => {
endpoint: gl.TEST_HOST,
initialStartDate: '2017-01-01',
initialEndDate: '2018-01-01',
initialLabels: labels,
updatePath: updateEndpoint,
labelsPath,
labelsWebUrl,
epicsWebUrl,
});
const datePickers = vm.$el.querySelectorAll('.block');
......@@ -68,6 +96,11 @@ describe('epicSidebar', () => {
vm = mountComponent(EpicSidebar, {
endpoint: gl.TEST_HOST,
initialStartDate: '2017-01-01',
initialLabels: labels,
updatePath: updateEndpoint,
labelsPath,
labelsWebUrl,
epicsWebUrl,
});
});
......@@ -89,7 +122,7 @@ describe('epicSidebar', () => {
});
it('should toggle contentContainer css class', () => {
const contentContainer = document.querySelector('.page-with-sidebar');
const contentContainer = document.querySelector('.page-with-contextual-sidebar');
expect(contentContainer.classList.contains('right-sidebar-expanded')).toEqual(true);
expect(contentContainer.classList.contains('right-sidebar-collapsed')).toEqual(false);
......@@ -113,6 +146,11 @@ describe('epicSidebar', () => {
component = new EpicSidebar({
propsData: {
endpoint: gl.TEST_HOST,
initialLabels: labels,
updatePath: updateEndpoint,
labelsPath,
labelsWebUrl,
epicsWebUrl,
},
});
});
......@@ -146,6 +184,38 @@ describe('epicSidebar', () => {
it('should handle errors gracefully', () => {});
});
describe('handleLabelClick', () => {
const label = {
id: 1,
title: 'Foo',
color: ['#BADA55'],
text_color: '#FFFFFF',
};
it('initializes `epicContext.labels` as empty array when `label.isAny` is `true`', () => {
const labelIsAny = { isAny: true };
vm.handleLabelClick(labelIsAny);
expect(Array.isArray(vm.epicContext.labels)).toBe(true);
expect(vm.epicContext.labels.length).toBe(0);
});
it('adds provided `label` to epicContext.labels', () => {
vm.handleLabelClick(label);
// epicContext.labels gets initialized with initialLabels, hence
// newly insert label will be at second position (index `1`)
expect(vm.epicContext.labels.length).toBe(2);
expect(vm.epicContext.labels[1].id).toBe(label.id);
vm.handleLabelClick(label);
});
it('filters epicContext.labels to exclude provided `label` if it is already present in `epicContext.labels`', () => {
vm.handleLabelClick(label); // Select
vm.handleLabelClick(label); // Un-select
expect(vm.epicContext.labels.length).toBe(1);
expect(vm.epicContext.labels[0].id).toBe(labels[0].id);
});
});
describe('saveDate error', () => {
let interceptor;
let component;
......@@ -160,6 +230,11 @@ describe('epicSidebar', () => {
component = new EpicSidebar({
propsData: {
endpoint: gl.TEST_HOST,
initialLabels: labels,
updatePath: updateEndpoint,
labelsPath,
labelsWebUrl,
epicsWebUrl,
},
});
});
......
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