Merge branch 'backport-add-epic-sidebar' into 'master'

Backport of add-epic-sidebar

See merge request gitlab-org/gitlab-ce!15335
This commit is contained in:
Fatih Acet 2017-11-27 20:21:53 +00:00
commit f8de23e626
17 changed files with 852 additions and 9 deletions

View file

@ -150,3 +150,17 @@ export function timeIntervalInWords(intervalInSeconds) {
}
return text;
}
export function dateInWords(date, abbreviated = false) {
if (!date) return date;
const month = date.getMonth();
const year = date.getFullYear();
const monthNames = [s__('January'), s__('February'), s__('March'), s__('April'), s__('May'), s__('June'), s__('July'), s__('August'), s__('September'), s__('October'), s__('November'), s__('December')];
const monthNamesAbbr = [s__('Jan'), s__('Feb'), s__('Mar'), s__('Apr'), s__('May'), s__('Jun'), s__('Jul'), s__('Aug'), s__('Sep'), s__('Oct'), s__('Nov'), s__('Dec')];
const monthName = abbreviated ? monthNamesAbbr[month] : monthNames[month];
return `${monthName} ${date.getDate()}, ${year}`;
}

View file

@ -55,3 +55,12 @@ export const slugify = str => str.trim().toLowerCase();
*/
export const truncate = (string, maxLength) => `${string.substr(0, (maxLength - 3))}...`;
/**
* Capitalizes first character
*
* @param {String} text
* @return {String}
*/
export function capitalizeFirstCharacter(text) {
return `${text[0].toUpperCase()}${text.slice(1)}`;
}

View file

@ -0,0 +1,79 @@
<script>
import Pikaday from 'pikaday';
import { parsePikadayDate, pikadayToString } from '../../lib/utils/datefix';
export default {
name: 'datePicker',
props: {
label: {
type: String,
required: false,
default: 'Date picker',
},
selectedDate: {
type: Date,
required: false,
},
minDate: {
type: Date,
required: false,
},
maxDate: {
type: Date,
required: false,
},
},
methods: {
selected(dateText) {
this.$emit('newDateSelected', this.calendar.toString(dateText));
},
toggled() {
this.$emit('hidePicker');
},
},
mounted() {
this.calendar = new Pikaday({
field: this.$el.querySelector('.dropdown-menu-toggle'),
theme: 'gitlab-theme animate-picker',
format: 'yyyy-mm-dd',
container: this.$el,
defaultDate: this.selectedDate,
setDefaultDate: !!this.selectedDate,
minDate: this.minDate,
maxDate: this.maxDate,
parse: dateString => parsePikadayDate(dateString),
toString: date => pikadayToString(date),
onSelect: this.selected.bind(this),
onClose: this.toggled.bind(this),
});
this.$el.append(this.calendar.el);
this.calendar.show();
},
beforeDestroy() {
this.calendar.destroy();
},
};
</script>
<template>
<div class="pikaday-container">
<div class="dropdown open">
<button
type="button"
class="dropdown-menu-toggle"
data-toggle="dropdown"
@click="toggled"
>
<span class="dropdown-toggle-text">
{{label}}
</span>
<i
class="fa fa-chevron-down"
aria-hidden="true"
>
</i>
</button>
</div>
</div>
</template>

View file

@ -0,0 +1,46 @@
<script>
export default {
name: 'collapsedCalendarIcon',
props: {
containerClass: {
type: String,
required: false,
default: '',
},
text: {
type: String,
required: false,
default: '',
},
showIcon: {
type: Boolean,
required: false,
default: true,
},
},
methods: {
click() {
this.$emit('click');
},
},
};
</script>
<template>
<div
:class="containerClass"
@click="click"
>
<i
v-if="showIcon"
class="fa fa-calendar"
aria-hidden="true"
>
</i>
<slot>
<span>
{{ text }}
</span>
</slot>
</div>
</template>

View file

@ -0,0 +1,109 @@
<script>
import { dateInWords } from '../../../lib/utils/datetime_utility';
import toggleSidebar from './toggle_sidebar.vue';
import collapsedCalendarIcon from './collapsed_calendar_icon.vue';
export default {
name: 'sidebarCollapsedGroupedDatePicker',
props: {
collapsed: {
type: Boolean,
required: false,
default: true,
},
showToggleSidebar: {
type: Boolean,
required: false,
default: false,
},
minDate: {
type: Date,
required: false,
},
maxDate: {
type: Date,
required: false,
},
disableClickableIcons: {
type: Boolean,
required: false,
default: false,
},
},
components: {
toggleSidebar,
collapsedCalendarIcon,
},
computed: {
hasMinAndMaxDates() {
return this.minDate && this.maxDate;
},
hasNoMinAndMaxDates() {
return !this.minDate && !this.maxDate;
},
showMinDateBlock() {
return this.minDate || this.hasNoMinAndMaxDates;
},
showFromText() {
return !this.maxDate && this.minDate;
},
iconClass() {
const disabledClass = this.disableClickableIcons ? 'disabled' : '';
return `block sidebar-collapsed-icon calendar-icon ${disabledClass}`;
},
},
methods: {
toggleSidebar() {
this.$emit('toggleCollapse');
},
dateText(dateType = 'min') {
const date = this[`${dateType}Date`];
const dateWords = dateInWords(date, true);
const parsedDateWords = dateWords ? dateWords.replace(',', '') : dateWords;
return date ? parsedDateWords : 'None';
},
},
};
</script>
<template>
<div class="block sidebar-grouped-item">
<div
v-if="showToggleSidebar"
class="issuable-sidebar-header"
>
<toggle-sidebar
:collapsed="collapsed"
@toggle="toggleSidebar"
/>
</div>
<collapsed-calendar-icon
v-if="showMinDateBlock"
:container-class="iconClass"
@click="toggleSidebar"
>
<span class="sidebar-collapsed-value">
<span v-if="showFromText">From</span>
<span>{{ dateText('min') }}</span>
</span>
</collapsed-calendar-icon>
<div
v-if="hasMinAndMaxDates"
class="text-center sidebar-collapsed-divider"
>
-
</div>
<collapsed-calendar-icon
v-if="maxDate"
:container-class="iconClass"
:show-icon="!minDate"
@click="toggleSidebar"
>
<span class="sidebar-collapsed-value">
<span v-if="!minDate">Until</span>
<span>{{ dateText('max') }}</span>
</span>
</collapsed-calendar-icon>
</div>
</template>

View file

@ -0,0 +1,163 @@
<script>
import datePicker from '../pikaday.vue';
import loadingIcon from '../loading_icon.vue';
import toggleSidebar from './toggle_sidebar.vue';
import collapsedCalendarIcon from './collapsed_calendar_icon.vue';
import { dateInWords } from '../../../lib/utils/datetime_utility';
export default {
name: 'sidebarDatePicker',
props: {
collapsed: {
type: Boolean,
required: false,
default: true,
},
showToggleSidebar: {
type: Boolean,
required: false,
default: false,
},
isLoading: {
type: Boolean,
required: false,
default: false,
},
editable: {
type: Boolean,
required: false,
default: false,
},
label: {
type: String,
required: false,
default: 'Date picker',
},
selectedDate: {
type: Date,
required: false,
},
minDate: {
type: Date,
required: false,
},
maxDate: {
type: Date,
required: false,
},
},
data() {
return {
editing: false,
};
},
components: {
datePicker,
toggleSidebar,
loadingIcon,
collapsedCalendarIcon,
},
computed: {
selectedAndEditable() {
return this.selectedDate && this.editable;
},
selectedDateWords() {
return dateInWords(this.selectedDate, true);
},
collapsedText() {
return this.selectedDateWords ? this.selectedDateWords : 'None';
},
},
methods: {
stopEditing() {
this.editing = false;
},
toggleDatePicker() {
this.editing = !this.editing;
},
newDateSelected(date = null) {
this.date = date;
this.editing = false;
this.$emit('saveDate', date);
},
toggleSidebar() {
this.$emit('toggleCollapse');
},
},
};
</script>
<template>
<div class="block">
<div class="issuable-sidebar-header">
<toggle-sidebar
:collapsed="collapsed"
@toggle="toggleSidebar"
/>
</div>
<collapsed-calendar-icon
class="sidebar-collapsed-icon"
:text="collapsedText"
/>
<div class="title">
{{ label }}
<loading-icon
v-if="isLoading"
:inline="true"
/>
<div class="pull-right">
<button
v-if="editable && !editing"
type="button"
class="btn-blank btn-link btn-primary-hover-link btn-sidebar-action"
@click="toggleDatePicker"
>
Edit
</button>
<toggle-sidebar
v-if="showToggleSidebar"
:collapsed="collapsed"
@toggle="toggleSidebar"
/>
</div>
</div>
<div class="value">
<date-picker
v-if="editing"
:selected-date="selectedDate"
:min-date="minDate"
:max-date="maxDate"
:label="label"
@newDateSelected="newDateSelected"
@hidePicker="stopEditing"
/>
<span
v-else
class="value-content"
>
<template v-if="selectedDate">
<strong>{{ selectedDateWords }}</strong>
<span
v-if="selectedAndEditable"
class="no-value"
>
-
<button
type="button"
class="btn-blank btn-link btn-secondary-hover-link"
@click="newDateSelected(null)"
>
remove
</button>
</span>
</template>
<span
v-else
class="no-value"
>
None
</span>
</span>
</div>
</div>
</template>

View file

@ -0,0 +1,30 @@
<script>
export default {
name: 'toggleSidebar',
props: {
collapsed: {
type: Boolean,
required: true,
},
},
methods: {
toggle() {
this.$emit('toggle');
},
},
};
</script>
<template>
<button
type="button"
class="btn btn-blank gutter-toggle btn-sidebar-action"
@click="toggle"
>
<i
aria-label="toggle collapse"
class="fa"
:class="{ 'fa-angle-double-right': !collapsed, 'fa-angle-double-left': collapsed }"
></i>
</button>
</template>

View file

@ -408,6 +408,7 @@
padding: 0;
background: transparent;
border: 0;
border-radius: 0;
&:hover,
&:active,
@ -417,3 +418,25 @@
box-shadow: none;
}
}
.btn-link.btn-secondary-hover-link {
color: $gl-text-color-secondary;
&:hover,
&:active,
&:focus {
color: $gl-link-color;
text-decoration: none;
}
}
.btn-link.btn-primary-hover-link {
color: inherit;
&:hover,
&:active,
&:focus {
color: $gl-link-color;
text-decoration: none;
}
}

View file

@ -43,11 +43,13 @@
}
.sidebar-collapsed-icon {
cursor: pointer;
.btn {
background-color: $gray-light;
}
&:not(.disabled) {
cursor: pointer;
}
}
}
@ -55,6 +57,10 @@
padding-right: 0;
z-index: 300;
.btn-sidebar-action {
display: inline-flex;
}
@media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) {
&:not(.wiki-sidebar):not(.build-sidebar):not(.issuable-bulk-update-sidebar) .content-wrapper {
padding-right: $gutter_collapsed_width;
@ -136,3 +142,18 @@
.issuable-sidebar {
@include new-style-dropdown;
}
.pikaday-container {
.pika-single {
margin-top: 2px;
width: 250px;
}
.dropdown-menu-toggle {
line-height: 20px;
}
}
.sidebar-collapsed-icon .sidebar-collapsed-value {
font-size: 12px;
}

View file

@ -276,10 +276,15 @@
font-weight: $gl-font-weight-normal;
}
.no-value {
.no-value,
.btn-secondary-hover-link {
color: $gl-text-color-secondary;
}
.btn-secondary-hover-link:hover {
color: $gl-link-color;
}
.sidebar-collapsed-icon {
display: none;
}
@ -287,6 +292,8 @@
.gutter-toggle {
margin-top: 7px;
border-left: 1px solid $border-gray-normal;
padding-left: 0;
text-align: center;
}
.title .gutter-toggle {
@ -359,7 +366,7 @@
fill: $issuable-sidebar-color;
}
&:hover,
&:hover:not(.disabled),
&:hover .todo-undone {
color: $gl-text-color;
@ -900,3 +907,21 @@
margin: 0 3px;
}
}
.right-sidebar-collapsed {
.sidebar-grouped-item {
.sidebar-collapsed-icon {
margin-bottom: 0;
}
.sidebar-collapsed-divider {
line-height: 5px;
font-size: 12px;
color: $theme-gray-700;
+ .sidebar-collapsed-icon {
padding-top: 0;
}
}
}
}

View file

@ -1,4 +1,4 @@
import { timeIntervalInWords } from '~/lib/utils/datetime_utility';
import * as datetimeUtility from '~/lib/utils/datetime_utility';
(() => {
describe('Date time utils', () => {
@ -89,10 +89,22 @@ import { timeIntervalInWords } from '~/lib/utils/datetime_utility';
describe('timeIntervalInWords', () => {
it('should return string with number of minutes and seconds', () => {
expect(timeIntervalInWords(9.54)).toEqual('9 seconds');
expect(timeIntervalInWords(1)).toEqual('1 second');
expect(timeIntervalInWords(200)).toEqual('3 minutes 20 seconds');
expect(timeIntervalInWords(6008)).toEqual('100 minutes 8 seconds');
expect(datetimeUtility.timeIntervalInWords(9.54)).toEqual('9 seconds');
expect(datetimeUtility.timeIntervalInWords(1)).toEqual('1 second');
expect(datetimeUtility.timeIntervalInWords(200)).toEqual('3 minutes 20 seconds');
expect(datetimeUtility.timeIntervalInWords(6008)).toEqual('100 minutes 8 seconds');
});
});
describe('dateInWords', () => {
const date = new Date('07/01/2016');
it('should return date in words', () => {
expect(datetimeUtility.dateInWords(date)).toEqual('July 1, 2016');
});
it('should return abbreviated month name', () => {
expect(datetimeUtility.dateInWords(date, true)).toEqual('Jul 1, 2016');
});
});
})();

View file

@ -23,6 +23,14 @@ describe('text_utility', () => {
});
});
describe('capitalizeFirstCharacter', () => {
it('returns string with first letter capitalized', () => {
expect(textUtils.capitalizeFirstCharacter('gitlab')).toEqual('Gitlab');
expect(textUtils.highCountTrim(105)).toBe('99+');
expect(textUtils.highCountTrim(100)).toBe('99+');
});
});
describe('humanize', () => {
it('should remove underscores and uppercase the first letter', () => {
expect(textUtils.humanize('foo_bar')).toEqual('Foo bar');

View file

@ -0,0 +1,29 @@
import Vue from 'vue';
import datePicker from '~/vue_shared/components/pikaday.vue';
import mountComponent from '../../helpers/vue_mount_component_helper';
describe('datePicker', () => {
let vm;
beforeEach(() => {
const DatePicker = Vue.extend(datePicker);
vm = mountComponent(DatePicker, {
label: 'label',
});
});
it('should render label text', () => {
expect(vm.$el.querySelector('.dropdown-toggle-text').innerText.trim()).toEqual('label');
});
it('should show calendar', () => {
expect(vm.$el.querySelector('.pika-single')).toBeDefined();
});
it('should toggle when dropdown is clicked', () => {
const hidePicker = jasmine.createSpy();
vm.$on('hidePicker', hidePicker);
vm.$el.querySelector('.dropdown-menu-toggle').click();
expect(hidePicker).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,35 @@
import Vue from 'vue';
import collapsedCalendarIcon from '~/vue_shared/components/sidebar/collapsed_calendar_icon.vue';
import mountComponent from '../../../helpers/vue_mount_component_helper';
describe('collapsedCalendarIcon', () => {
let vm;
beforeEach(() => {
const CollapsedCalendarIcon = Vue.extend(collapsedCalendarIcon);
vm = mountComponent(CollapsedCalendarIcon, {
containerClass: 'test-class',
text: 'text',
showIcon: false,
});
});
it('should add class to container', () => {
expect(vm.$el.classList.contains('test-class')).toEqual(true);
});
it('should hide calendar icon if showIcon', () => {
expect(vm.$el.querySelector('.fa-calendar')).toBeNull();
});
it('should render text', () => {
expect(vm.$el.querySelector('span').innerText.trim()).toEqual('text');
});
it('should emit click event when container is clicked', () => {
const click = jasmine.createSpy();
vm.$on('click', click);
vm.$el.click();
expect(click).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,91 @@
import Vue from 'vue';
import collapsedGroupedDatePicker from '~/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue';
import mountComponent from '../../../helpers/vue_mount_component_helper';
describe('collapsedGroupedDatePicker', () => {
let vm;
beforeEach(() => {
const CollapsedGroupedDatePicker = Vue.extend(collapsedGroupedDatePicker);
vm = mountComponent(CollapsedGroupedDatePicker, {
showToggleSidebar: true,
});
});
it('should render toggle sidebar if showToggleSidebar', (done) => {
expect(vm.$el.querySelector('.issuable-sidebar-header')).toBeDefined();
vm.showToggleSidebar = false;
Vue.nextTick(() => {
expect(vm.$el.querySelector('.issuable-sidebar-header')).toBeNull();
done();
});
});
it('toggleCollapse events', () => {
const toggleCollapse = jasmine.createSpy();
beforeEach((done) => {
vm.minDate = new Date('07/17/2016');
Vue.nextTick(done);
});
it('should emit when sidebar is toggled', () => {
vm.$el.querySelector('.gutter-toggle').click();
expect(toggleCollapse).toHaveBeenCalled();
});
it('should emit when collapsed-calendar-icon is clicked', () => {
vm.$el.querySelector('.sidebar-collapsed-icon').click();
expect(toggleCollapse).toHaveBeenCalled();
});
});
describe('minDate and maxDate', () => {
beforeEach((done) => {
vm.minDate = new Date('07/17/2016');
vm.maxDate = new Date('07/17/2017');
Vue.nextTick(done);
});
it('should render both collapsed-calendar-icon', () => {
const icons = vm.$el.querySelectorAll('.sidebar-collapsed-icon');
expect(icons.length).toEqual(2);
expect(icons[0].innerText.trim()).toEqual('Jul 17 2016');
expect(icons[1].innerText.trim()).toEqual('Jul 17 2017');
});
});
describe('minDate', () => {
beforeEach((done) => {
vm.minDate = new Date('07/17/2016');
Vue.nextTick(done);
});
it('should render minDate in collapsed-calendar-icon', () => {
const icons = vm.$el.querySelectorAll('.sidebar-collapsed-icon');
expect(icons.length).toEqual(1);
expect(icons[0].innerText.trim()).toEqual('From Jul 17 2016');
});
});
describe('maxDate', () => {
beforeEach((done) => {
vm.maxDate = new Date('07/17/2017');
Vue.nextTick(done);
});
it('should render maxDate in collapsed-calendar-icon', () => {
const icons = vm.$el.querySelectorAll('.sidebar-collapsed-icon');
expect(icons.length).toEqual(1);
expect(icons[0].innerText.trim()).toEqual('Until Jul 17 2017');
});
});
describe('no dates', () => {
it('should render None', () => {
const icons = vm.$el.querySelectorAll('.sidebar-collapsed-icon');
expect(icons.length).toEqual(1);
expect(icons[0].innerText.trim()).toEqual('None');
});
});
});

View file

@ -0,0 +1,117 @@
import Vue from 'vue';
import sidebarDatePicker from '~/vue_shared/components/sidebar/date_picker.vue';
import mountComponent from '../../../helpers/vue_mount_component_helper';
describe('sidebarDatePicker', () => {
let vm;
beforeEach(() => {
const SidebarDatePicker = Vue.extend(sidebarDatePicker);
vm = mountComponent(SidebarDatePicker, {
label: 'label',
isLoading: true,
});
});
it('should emit toggleCollapse when collapsed toggle sidebar is clicked', () => {
const toggleCollapse = jasmine.createSpy();
vm.$on('toggleCollapse', toggleCollapse);
vm.$el.querySelector('.issuable-sidebar-header .gutter-toggle').click();
expect(toggleCollapse).toHaveBeenCalled();
});
it('should render collapsed-calendar-icon', () => {
expect(vm.$el.querySelector('.sidebar-collapsed-icon')).toBeDefined();
});
it('should render label', () => {
expect(vm.$el.querySelector('.title').innerText.trim()).toEqual('label');
});
it('should render loading-icon when isLoading', () => {
expect(vm.$el.querySelector('.fa-spin')).toBeDefined();
});
it('should render value when not editing', () => {
expect(vm.$el.querySelector('.value-content')).toBeDefined();
});
it('should render None if there is no selectedDate', () => {
expect(vm.$el.querySelector('.value-content span').innerText.trim()).toEqual('None');
});
it('should render date-picker when editing', (done) => {
vm.editing = true;
Vue.nextTick(() => {
expect(vm.$el.querySelector('.pika-label')).toBeDefined();
done();
});
});
describe('editable', () => {
beforeEach((done) => {
vm.editable = true;
Vue.nextTick(done);
});
it('should render edit button', () => {
expect(vm.$el.querySelector('.title .btn-blank').innerText.trim()).toEqual('Edit');
});
it('should enable editing when edit button is clicked', (done) => {
vm.isLoading = false;
Vue.nextTick(() => {
vm.$el.querySelector('.title .btn-blank').click();
expect(vm.editing).toEqual(true);
done();
});
});
});
it('should render date if selectedDate', (done) => {
vm.selectedDate = new Date('07/07/2017');
Vue.nextTick(() => {
expect(vm.$el.querySelector('.value-content strong').innerText.trim()).toEqual('Jul 7, 2017');
done();
});
});
describe('selectedDate and editable', () => {
beforeEach((done) => {
vm.selectedDate = new Date('07/07/2017');
vm.editable = true;
Vue.nextTick(done);
});
it('should render remove button if selectedDate and editable', () => {
expect(vm.$el.querySelector('.value-content .btn-blank').innerText.trim()).toEqual('remove');
});
it('should emit saveDate when remove button is clicked', () => {
const saveDate = jasmine.createSpy();
vm.$on('saveDate', saveDate);
vm.$el.querySelector('.value-content .btn-blank').click();
expect(saveDate).toHaveBeenCalled();
});
});
describe('showToggleSidebar', () => {
beforeEach((done) => {
vm.showToggleSidebar = true;
Vue.nextTick(done);
});
it('should render toggle-sidebar when showToggleSidebar', () => {
expect(vm.$el.querySelector('.title .gutter-toggle')).toBeDefined();
});
it('should emit toggleCollapse when toggle sidebar is clicked', () => {
const toggleCollapse = jasmine.createSpy();
vm.$on('toggleCollapse', toggleCollapse);
vm.$el.querySelector('.title .gutter-toggle').click();
expect(toggleCollapse).toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,32 @@
import Vue from 'vue';
import toggleSidebar from '~/vue_shared/components/sidebar/toggle_sidebar.vue';
import mountComponent from '../../../helpers/vue_mount_component_helper';
describe('toggleSidebar', () => {
let vm;
beforeEach(() => {
const ToggleSidebar = Vue.extend(toggleSidebar);
vm = mountComponent(ToggleSidebar, {
collapsed: true,
});
});
it('should render << when collapsed', () => {
expect(vm.$el.querySelector('.fa').classList.contains('fa-angle-double-left')).toEqual(true);
});
it('should render >> when collapsed', () => {
vm.collapsed = false;
Vue.nextTick(() => {
expect(vm.$el.querySelector('.fa').classList.contains('fa-angle-double-right')).toEqual(true);
});
});
it('should emit toggle event when button clicked', () => {
const toggle = jasmine.createSpy();
vm.$on('toggle', toggle);
vm.$el.click();
expect(toggle).toHaveBeenCalled();
});
});