Commit 6055dba2 authored by Kushal Pandya's avatar Kushal Pandya

Add support for labels in Epic sidebar

Adds labels picker within Sidebar.
Supports auto-expanding collapsed sidebar and reveal labels dropdown.
parent 05a78b72
<script> <script>
import SidebarContext from '../sidebar_context';
import EpicHeader from './epic_header.vue'; import EpicHeader from './epic_header.vue';
import EpicBody from './epic_body.vue'; import EpicBody from './epic_body.vue';
...@@ -7,6 +9,9 @@ export default { ...@@ -7,6 +9,9 @@ export default {
EpicHeader, EpicHeader,
EpicBody, EpicBody,
}, },
mounted() {
this.sidebarContext = new SidebarContext();
},
}; };
</script> </script>
......
...@@ -7,6 +7,7 @@ import SidebarHeader from './sidebar_items/sidebar_header.vue'; ...@@ -7,6 +7,7 @@ import SidebarHeader from './sidebar_items/sidebar_header.vue';
import SidebarTodo from './sidebar_items/sidebar_todo.vue'; import SidebarTodo from './sidebar_items/sidebar_todo.vue';
import SidebarDatePicker from './sidebar_items/sidebar_date_picker.vue'; import SidebarDatePicker from './sidebar_items/sidebar_date_picker.vue';
import SidebarDatePickerCollapsed from '~/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue'; import SidebarDatePickerCollapsed from '~/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue';
import SidebarLabels from './sidebar_items/sidebar_labels.vue';
import { dateTypes } from '../constants'; import { dateTypes } from '../constants';
...@@ -17,6 +18,7 @@ export default { ...@@ -17,6 +18,7 @@ export default {
SidebarTodo, SidebarTodo,
SidebarDatePicker, SidebarDatePicker,
SidebarDatePickerCollapsed, SidebarDatePickerCollapsed,
SidebarLabels,
}, },
computed: { computed: {
...mapState([ ...mapState([
...@@ -172,6 +174,7 @@ export default { ...@@ -172,6 +174,7 @@ export default {
:max-date="dueDateForCollapsedSidebar" :max-date="dueDateForCollapsedSidebar"
@toggleCollapse="toggleSidebar({ sidebarCollapsed })" @toggleCollapse="toggleSidebar({ sidebarCollapsed })"
/> />
<sidebar-labels :can-update="canUpdate" :sidebar-collapsed="sidebarCollapsed" />
</div> </div>
</aside> </aside>
</template> </template>
<script>
import { mapState, mapActions } from 'vuex';
import _ from 'underscore';
import ListLabel from '~/vue_shared/models/label';
import LabelsSelect from '~/vue_shared/components/sidebar/labels_select/base.vue';
export default {
components: {
LabelsSelect,
},
props: {
canUpdate: {
type: Boolean,
required: true,
},
sidebarCollapsed: {
type: Boolean,
required: true,
},
},
data() {
return {
sidebarExpandedOnClick: false,
};
},
computed: {
...mapState([
'labels',
'namespace',
'updateEndpoint',
'labelsPath',
'labelsWebUrl',
'epicsWebUrl',
]),
epicContext() {
return {
labels: this.labels,
};
},
},
methods: {
...mapActions(['toggleSidebar']),
toggleSidebarRevealLabelsDropdown() {
const contentContainer = this.$el.closest('.page-with-contextual-sidebar');
this.toggleSidebar({ sidebarCollapsed: this.sidebarCollapsed });
// When sidebar is expanded, we need to wait
// for rendering to finish before opening
// dropdown as otherwise it causes `calc()`
// used in CSS to miscalculate collapsed
// sidebar size.
_.debounce(() => {
this.sidebarExpandedOnClick = true;
if (contentContainer) {
contentContainer
.querySelector('.js-sidebar-dropdown-toggle')
.dispatchEvent(new Event('click', { bubbles: true, cancelable: false }));
}
}, 100)();
},
handleDropdownClose() {
if (this.sidebarExpandedOnClick) {
this.sidebarExpandedOnClick = false;
this.toggleSidebar({ sidebarCollapsed: this.sidebarCollapsed });
}
},
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>
<template>
<labels-select
:can-edit="canUpdate"
:context="epicContext"
:namespace="namespace"
:update-path="updateEndpoint"
:labels-path="labelsPath"
:labels-web-url="labelsWebUrl"
:label-filter-base-path="epicsWebUrl"
:show-create="true"
ability-name="epic"
@onLabelClick="handleLabelClick"
@onDropdownClose="handleDropdownClose"
@toggleCollapse="toggleSidebarRevealLabelsDropdown"
>{{ __('None') }}</labels-select
>
</template>
import $ from 'jquery';
import Cookies from 'js-cookie';
import bp from '~/breakpoints';
export default class SidebarContext {
constructor() {
const $issuableSidebar = $('.js-issuable-update');
$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
const isVisible = !!$selectbox.get(0).offsetParent;
$selectbox.toggle(!isVisible);
$block.find('.js-value').toggle(isVisible);
if ($selectbox.get(0).offsetParent) {
setTimeout(() => $block.find('.js-label-select').trigger('click'), 0);
}
});
window.addEventListener('beforeunload', () => {
// collapsed_gutter cookie hides the sidebar
const bpBreakpoint = bp.getBreakpointSize();
if (bpBreakpoint === 'xs' || bpBreakpoint === 'sm') {
Cookies.set('collapsed_gutter', true);
}
});
}
}
...@@ -25,6 +25,7 @@ export default () => ({ ...@@ -25,6 +25,7 @@ export default () => ({
// Epic Information // Epic Information
epicId: 0, epicId: 0,
namespace: '#',
state: '', state: '',
created: '', created: '',
author: null, author: null,
......
...@@ -198,5 +198,9 @@ describe('EpicSidebarComponent', () => { ...@@ -198,5 +198,9 @@ describe('EpicSidebarComponent', () => {
.then(done) .then(done)
.catch(done.fail); .catch(done.fail);
}); });
it('renders labels select element', () => {
expect(vm.$el.querySelector('.js-labels-block')).not.toBeNull();
});
}); });
}); });
import Vue from 'vue';
import SidebarLabels from 'ee/epic/components/sidebar_items/sidebar_labels.vue';
import createStore from 'ee/epic/store';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import { mockEpicMeta, mockEpicData, mockLabels } from '../../mock_data';
describe('SidebarLabelsComponent', () => {
let vm;
let store;
beforeEach(done => {
const Component = Vue.extend(SidebarLabels);
store = createStore();
store.dispatch('setEpicMeta', mockEpicMeta);
store.dispatch('setEpicData', mockEpicData);
vm = mountComponentWithStore(Component, {
store,
props: { canUpdate: false, sidebarCollapsed: false },
});
setTimeout(done);
});
afterEach(() => {
vm.$destroy();
});
describe('data', () => {
it('returns default data props', () => {
expect(vm.sidebarExpandedOnClick).toBe(false);
});
});
describe('computed', () => {
describe('epicContext', () => {
it('returns object containing `this.labels` as a child prop', () => {
expect(vm.epicContext.labels).toBe(vm.labels);
});
});
});
describe('methods', () => {
describe('toggleSidebarRevealLabelsDropdown', () => {
it('calls `toggleSidebar` action with param `sidebarCollapse`', () => {
spyOn(vm, 'toggleSidebar');
vm.toggleSidebarRevealLabelsDropdown();
expect(vm.toggleSidebar).toHaveBeenCalledWith(
jasmine.objectContaining({
sidebarCollapsed: false,
}),
);
});
});
describe('handleDropdownClose', () => {
it('calls `toggleSidebar` action only when `sidebarExpandedOnClick` prop is true', () => {
spyOn(vm, 'toggleSidebar');
vm.sidebarExpandedOnClick = true;
vm.handleDropdownClose();
expect(vm.sidebarExpandedOnClick).toBe(false);
expect(vm.toggleSidebar).toHaveBeenCalledWith(
jasmine.objectContaining({
sidebarCollapsed: false,
}),
);
});
});
describe('handleLabelClick', () => {
const label = {
id: 1,
title: 'Foo',
color: ['#BADA55'],
text_color: '#FFFFFF',
};
beforeEach(() => {
store.state.labels = mockLabels;
});
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(mockLabels[0].id);
});
});
});
describe('template', () => {
it('renders labels select element container', () => {
expect(vm.$el.classList.contains('js-labels-block')).toBe(true);
});
});
});
...@@ -34,3 +34,13 @@ export const mockDatePickerProps = { ...@@ -34,3 +34,13 @@ export const mockDatePickerProps = {
isDateInvalid: false, isDateInvalid: false,
dateInvalidTooltip: 'Selected date is invalid', dateInvalidTooltip: 'Selected date is invalid',
}; };
export const mockLabels = [
{
id: 26,
title: 'Foo Label',
description: 'Foobar',
color: '#BADA55',
text_color: '#FFFFFF',
},
];
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