Commit 7c3349ab authored by Filipa Lacerda's avatar Filipa Lacerda

Merge branch '3729-fix-epics-roadmap-empty-states' into 'master'

Make empty state copy aware of applied filters in Epics list and Roadmap view

See merge request gitlab-org/gitlab-ee!5465
parents 720c9d60 037e7241
......@@ -22,6 +22,14 @@
type: Object,
required: true,
},
hasFiltersApplied: {
type: Boolean,
required: true,
},
newEpicEndpoint: {
type: String,
required: true,
},
emptyStateIllustrationPath: {
type: String,
required: true,
......@@ -109,7 +117,10 @@
</script>
<template>
<div class="roadmap-container">
<div
class="roadmap-container"
:class="{ 'overflow-reset': isEpicsListEmpty }"
>
<loading-icon
class="loading-animation prepend-top-20 append-bottom-20"
size="2"
......@@ -126,6 +137,8 @@
v-if="isEpicsListEmpty"
:timeframe-start="timeframeStart"
:timeframe-end="timeframeEnd"
:has-filters-applied="hasFiltersApplied"
:new-epic-endpoint="newEpicEndpoint"
:empty-state-illustration-path="emptyStateIllustrationPath"
/>
</div>
......
......@@ -2,7 +2,12 @@
import { s__, sprintf } from '~/locale';
import { dateInWords } from '~/lib/utils/datetime_utility';
import NewEpic from '../../epics/new_epic/components/new_epic.vue';
export default {
components: {
NewEpic,
},
props: {
timeframeStart: {
type: Date,
......@@ -12,6 +17,14 @@
type: Date,
required: true,
},
hasFiltersApplied: {
type: Boolean,
required: true,
},
newEpicEndpoint: {
type: String,
required: true,
},
emptyStateIllustrationPath: {
type: String,
required: true,
......@@ -32,9 +45,18 @@
};
},
message() {
return s__('GroupRoadmap|Epics let you manage your portfolio of projects more efficiently and with less effort');
if (this.hasFiltersApplied) {
return s__('GroupRoadmap|Sorry, no epics matched your search');
}
return s__('GroupRoadmap|The roadmap shows the progress of your epics along a timeline');
},
subMessage() {
if (this.hasFiltersApplied) {
return sprintf(s__('GroupRoadmap|To widen your search, change or remove filters. Only epics in the past 3 months and the next 3 months are shown &ndash; from %{startDate} to %{endDate}.'), {
startDate: this.timeframeRange.startDate,
endDate: this.timeframeRange.endDate,
});
}
return sprintf(s__('GroupRoadmap|To view the roadmap, add a planned start or finish date to one of your epics in this group or its subgroups. Only epics in the past 3 months and the next 3 months are shown &ndash; from %{startDate} to %{endDate}.'), {
startDate: this.timeframeRange.startDate,
endDate: this.timeframeRange.endDate,
......@@ -57,6 +79,17 @@
<div class="text-content">
<h4>{{ message }}</h4>
<p v-html="subMessage"></p>
<new-epic
v-if="!hasFiltersApplied"
:endpoint="newEpicEndpoint"
/>
<a
class="btn btn-default"
:title="__('List')"
:href="newEpicEndpoint"
>
<span>{{ s__('View epics list') }}</span>
</a>
</div>
</div>
</div>
......
......@@ -2,6 +2,7 @@ import Vue from 'vue';
import Translate from '~/vue_shared/translate';
import { convertPermissionToBoolean } from '~/lib/utils/common_utils';
import { getTimeframeWindow } from '~/lib/utils/datetime_utility';
import { TIMEFRAME_LENGTH } from './constants';
......@@ -27,6 +28,7 @@ export default () => {
},
data() {
const dataset = this.$options.el.dataset;
const hasFiltersApplied = convertPermissionToBoolean(dataset.hasFiltersApplied);
const filterQueryString = window.location.search.substring(1);
// Construct Epic API path to include
......@@ -49,6 +51,8 @@ export default () => {
return {
store,
service,
hasFiltersApplied,
newEpicEndpoint: dataset.newEpicEndpoint,
emptyStateIllustrationPath: dataset.emptyStateIllustration,
};
},
......@@ -57,6 +61,8 @@ export default () => {
props: {
store: this.store,
service: this.service,
hasFiltersApplied: this.hasFiltersApplied,
newEpicEndpoint: this.newEpicEndpoint,
emptyStateIllustrationPath: this.emptyStateIllustrationPath,
},
});
......
......@@ -20,6 +20,10 @@ $column-right-gradient: linear-gradient(to right, rgba(0, 0, 0, 0.15) 0%, rgba(2
.roadmap-container {
overflow: hidden;
&.overflow-reset {
overflow: initial;
}
}
.roadmap-shell {
......
- has_filters_applied = params[:label_name].present? || params[:author_username].present? || params[:search].present?
- page_title "Epics"
.top-area
= render 'shared/issuable/epic_nav', type: :epics
.nav-controls
- if can?(current_user, :create_epic, @group)
#new-epic-app{ data: { endpoint: request.url, 'align-right' => true } }
- if has_filters_applied || @epics.to_a.any?
.top-area
= render 'shared/issuable/epic_nav', type: :epics
.nav-controls
- if can?(current_user, :create_epic, @group)
#new-epic-app{ data: { endpoint: request.url, 'align-right' => true } }
= render 'shared/epic/search_bar', type: :epics
= render 'shared/epic/search_bar', type: :epics
- if @epics.to_a.any?
= render 'shared/epics'
......
......@@ -4,9 +4,11 @@
- @content_class = "group-epics-roadmap"
- breadcrumb_title _("Epics Roadmap")
- has_filters_applied = params[:label_name].present? || params[:author_username].present? || params[:search].present?
- if @epics_count != 0
= render 'shared/epic/search_bar', type: :epics, hide_sort_dropdown: true
#js-roadmap{ data: { epics_path: group_epics_path(@group, format: :json), group_id: @group.id, empty_state_illustration: image_path('illustrations/epics/roadmap.svg') } }
#js-roadmap{ data: { epics_path: group_epics_path(@group, format: :json), group_id: @group.id, empty_state_illustration: image_path('illustrations/epics/roadmap.svg'), has_filters_applied: "#{has_filters_applied}", new_epic_endpoint: group_epics_path(@group) } }
- else
= render 'shared/empty_states/roadmap'
- has_filters_applied = params[:label_name].present? || params[:author_username].present? || params[:search].present?
.row.empty-state
.col-xs-12
.svg-content
......@@ -5,8 +7,14 @@
.col-xs-12
.text-content
%h4
= _('Epics let you manage your portfolio of projects more efficiently and with less effort')
- if has_filters_applied
= _('Sorry, no epics matched your search')
- else
= _('Epics let you manage your portfolio of projects more efficiently and with less effort')
%p
= _('Track groups of issues that share a theme, across projects and milestones')
- if has_filters_applied
= _('To widen your search, change or remove filters. If something is missing, create an epic.')
- else
= _('Track groups of issues that share a theme, across projects and milestones')
- if can?(current_user, :create_epic, @group)
#new-epic-app{ data: { endpoint: request.url } }
......@@ -8,7 +8,7 @@ import RoadmapStore from 'ee/roadmap/store/roadmap_store';
import RoadmapService from 'ee/roadmap/service/roadmap_service';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockTimeframe, mockGroupId, epicsPath, rawEpics, mockSvgPath } from '../mock_data';
import { mockTimeframe, mockGroupId, epicsPath, mockNewEpicEndpoint, rawEpics, mockSvgPath } from '../mock_data';
const createComponent = () => {
const Component = Vue.extend(appComponent);
......@@ -20,6 +20,8 @@ const createComponent = () => {
return mountComponent(Component, {
store,
service,
hasFiltersApplied: true,
newEpicEndpoint: mockNewEpicEndpoint,
emptyStateIllustrationPath: mockSvgPath,
});
};
......@@ -179,5 +181,16 @@ describe('AppComponent', () => {
it('renders roadmap container with class `roadmap-container`', () => {
expect(vm.$el.classList.contains('roadmap-container')).toBe(true);
});
it('renders roadmap container with classes `roadmap-container overflow-reset` when isEpicsListEmpty prop is true', (done) => {
vm.isEpicsListEmpty = true;
Vue.nextTick()
.then(() => {
expect(vm.$el.classList.contains('roadmap-container')).toBe(true);
expect(vm.$el.classList.contains('overflow-reset')).toBe(true);
})
.then(done)
.catch(done.fail);
});
});
});
......@@ -3,15 +3,17 @@ import Vue from 'vue';
import epicsListEmptyComponent from 'ee/roadmap/components/epics_list_empty.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockTimeframe, mockSvgPath } from '../mock_data';
import { mockTimeframe, mockSvgPath, mockNewEpicEndpoint } from '../mock_data';
const createComponent = () => {
const createComponent = (hasFiltersApplied = false) => {
const Component = Vue.extend(epicsListEmptyComponent);
return mountComponent(Component, {
timeframeStart: mockTimeframe[0],
timeframeEnd: mockTimeframe[mockTimeframe.length - 1],
emptyStateIllustrationPath: mockSvgPath,
newEpicEndpoint: mockNewEpicEndpoint,
hasFiltersApplied,
});
};
......@@ -28,15 +30,35 @@ describe('EpicsListEmptyComponent', () => {
describe('computed', () => {
describe('message', () => {
it('returns correct empty state message', () => {
expect(vm.message).toBe('Epics let you manage your portfolio of projects more efficiently and with less effort');
it('returns default empty state message', () => {
expect(vm.message).toBe('The roadmap shows the progress of your epics along a timeline');
});
it('returns empty state message when `hasFiltersApplied` prop is true', (done) => {
vm.hasFiltersApplied = true;
Vue.nextTick()
.then(() => {
expect(vm.message).toBe('Sorry, no epics matched your search');
})
.then(done)
.catch(done.fail);
});
});
describe('subMessage', () => {
it('returns correct empty state sub-message', () => {
it('returns default empty state sub-message', () => {
expect(vm.subMessage).toBe('To view the roadmap, add a planned start or finish date to one of your epics in this group or its subgroups. Only epics in the past 3 months and the next 3 months are shown &ndash; from Nov 1, 2017 to Apr 30, 2018.');
});
it('returns empty state sub-message when `hasFiltersApplied` prop is true', done => {
vm.hasFiltersApplied = true;
Vue.nextTick()
.then(() => {
expect(vm.subMessage).toBe('To widen your search, change or remove filters. Only epics in the past 3 months and the next 3 months are shown &ndash; from Nov 1, 2017 to Apr 30, 2018.');
})
.then(done)
.catch(done.fail);
});
});
describe('timeframeRange', () => {
......@@ -51,5 +73,30 @@ describe('EpicsListEmptyComponent', () => {
it('renders empty state illustration in image element with provided `emptyStateIllustrationPath`', () => {
expect(vm.$el.querySelector('.svg-content img').getAttribute('src')).toBe(vm.emptyStateIllustrationPath);
});
it('renders new epic button element', () => {
const newEpicBtnEl = vm.$el.querySelector('.new-epic-dropdown');
expect(newEpicBtnEl).not.toBeNull();
expect(newEpicBtnEl.querySelector('button.btn-new').innerText.trim()).toBe('New epic');
});
it('does not render new epic button element when `hasFiltersApplied` prop is true', done => {
vm.hasFiltersApplied = true;
Vue.nextTick()
.then(() => {
expect(vm.$el.querySelector('.new-epic-dropdown')).toBeNull();
})
.then(done)
.catch(done.fail);
});
it('renders view epics list link element', () => {
const viewEpicsListEl = vm.$el.querySelector('a.btn');
expect(viewEpicsListEl).not.toBeNull();
expect(viewEpicsListEl.getAttribute('href')).toBe(mockNewEpicEndpoint);
expect(viewEpicsListEl.querySelector('span').innerText.trim()).toBe('View epics list');
});
});
});
......@@ -11,6 +11,8 @@ export const mockItemWidth = 180;
export const epicsPath = '/groups/gitlab-org/-/epics.json?start_date=2017-11-1&end_date=2018-4-30';
export const mockNewEpicEndpoint = '/groups/gitlab-org/-/epics';
export const mockSvgPath = '/foo/bar.svg';
export const mockTimeframe = getTimeframeWindow(TIMEFRAME_LENGTH, new Date(2018, 1, 1));
......
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