Commit 249d54e4 authored by Martin Wortschack's avatar Martin Wortschack

Merge branch '207981-refine-empty-state-and-loading-animations' into 'master'

[Part-4] Refine empty state & loading animation

Closes #207981

See merge request gitlab-org/gitlab!29320
parents ccbf3cf6 2fc9a630
<script>
import { GlEmptyState } from '@gitlab/ui';
import { GlEmptyState, GlDeprecatedButton } from '@gitlab/ui';
import { __ } from '~/locale';
import { FilterStateEmptyMessage } from '../constants';
import { FilterState, FilterStateEmptyMessage } from '../constants';
export default {
components: {
GlEmptyState,
GlDeprecatedButton,
},
props: {
filterBy: {
......@@ -16,10 +18,24 @@ export default {
type: String,
required: true,
},
requirementsCount: {
type: Object,
required: true,
},
},
computed: {
emptyStateTitle() {
return FilterStateEmptyMessage[this.filterBy];
return this.requirementsCount[FilterState.all]
? FilterStateEmptyMessage[this.filterBy]
: __('Requirements allow you to create criteria to check your products against.');
},
emptyStateDescription() {
return !this.requirementsCount[FilterState.all]
? __(
`Requirements can be based on users, stakeholders, system, software
or anything else you find important to capture.`,
)
: null;
},
},
};
......@@ -27,6 +43,19 @@ export default {
<template>
<div class="requirements-empty-state-container">
<gl-empty-state :title="emptyStateTitle" :svg-path="emptyStatePath" />
<gl-empty-state
:svg-path="emptyStatePath"
:title="emptyStateTitle"
:description="emptyStateDescription"
>
<template v-if="emptyStateDescription" #actions>
<gl-deprecated-button
category="primary"
variant="success"
@click="$emit('clickNewRequirement')"
>{{ __('New requirement') }}</gl-deprecated-button
>
</template>
</gl-empty-state>
</div>
</template>
<script>
import { GlSkeletonLoading } from '@gitlab/ui';
import { GlSkeletonLoading, GlLoadingIcon } from '@gitlab/ui';
import { DEFAULT_PAGE_SIZE } from '../constants';
import { DEFAULT_PAGE_SIZE, FilterState } from '../constants';
export default {
components: {
GlSkeletonLoading,
GlLoadingIcon,
},
props: {
filterBy: {
type: String,
required: true,
},
currentTabCount: {
currentPage: {
type: Number,
required: true,
},
currentPage: {
type: Number,
requirementsCount: {
type: Object,
required: true,
},
},
computed: {
currentTabCount() {
return this.requirementsCount[this.filterBy];
},
totalRequirements() {
return this.requirementsCount[FilterState.all];
},
lastPage() {
return Math.ceil(this.currentTabCount / DEFAULT_PAGE_SIZE);
},
......@@ -36,9 +43,13 @@ export default {
</script>
<template>
<ul class="content-list issuable-list issues-list requirements-list-loading">
<ul
v-if="totalRequirements && currentTabCount"
class="content-list issuable-list issues-list requirements-list-loading"
>
<li v-for="(i, index) in Array(loaderCount).fill()" :key="index" class="issue requirement">
<gl-skeleton-loading :lines="2" class="pt-2" />
</li>
</ul>
<gl-loading-icon v-else size="md" class="mt-3" />
</template>
......@@ -4,7 +4,7 @@ import { GlPagination } from '@gitlab/ui';
import { __ } from '~/locale';
import createFlash from '~/flash';
import { urlParamsToObject } from '~/lib/utils/common_utils';
import { updateHistory, setUrlParams } from '~/lib/utils/url_utility';
import { updateHistory, setUrlParams, visitUrl } from '~/lib/utils/url_utility';
import RequirementsLoading from './requirements_loading.vue';
import RequirementsEmptyState from './requirements_empty_state.vue';
......@@ -38,7 +38,8 @@ export default {
requirementsCount: {
type: Object,
required: true,
validator: value => ['OPENED', 'ARCHIVED', 'ALL'].every(prop => value[prop]),
validator: value =>
['OPENED', 'ARCHIVED', 'ALL'].every(prop => typeof value[prop] === 'number'),
},
page: {
type: Number,
......@@ -59,6 +60,10 @@ export default {
type: String,
required: true,
},
requirementsWebUrl: {
type: String,
required: true,
},
},
apollo: {
requirements: {
......@@ -108,6 +113,7 @@ export default {
const tabsContainerEl = document.querySelector('.js-requirements-state-filters');
return {
newRequirementEl: null,
showCreateForm: false,
showUpdateFormForRequirement: 0,
createRequirementRequestActive: false,
......@@ -158,6 +164,11 @@ export default {
},
},
watch: {
showCreateForm(value) {
this.enableOrDisableNewRequirement({
disable: value,
});
},
requirements() {
const totalCount = this.requirements.count.ALL;
......@@ -174,16 +185,14 @@ export default {
},
mounted() {
if (this.filterBy === FilterState.opened) {
document
.querySelector('.js-new-requirement')
.addEventListener('click', this.handleNewRequirementClick);
this.newRequirementEl = document.querySelector('.js-new-requirement');
this.newRequirementEl.addEventListener('click', this.handleNewRequirementClick);
}
},
beforeDestroy() {
if (this.filterBy === FilterState.opened) {
document
.querySelector('.js-new-requirement')
.removeEventListener('click', this.handleNewRequirementClick);
this.newRequirementEl.removeEventListener('click', this.handleNewRequirementClick);
}
},
methods: {
......@@ -241,6 +250,22 @@ export default {
Sentry.captureException(e);
});
},
/**
* This method is only needed until we move Requirements page
* tabs and button into this Vue app instead of rendering it
* using HAML.
*/
enableOrDisableNewRequirement({ disable = true }) {
if (this.newRequirementEl) {
if (disable) {
this.newRequirementEl.setAttribute('disabled', 'disabled');
this.newRequirementEl.classList.add('disabled');
} else {
this.newRequirementEl.removeAttribute('disabled');
this.newRequirementEl.classList.remove('disabled');
}
}
},
handleNewRequirementClick() {
this.showCreateForm = true;
},
......@@ -248,6 +273,7 @@ export default {
this.showUpdateFormForRequirement = iid;
},
handleNewRequirementSave(title) {
const reloadPage = this.totalRequirements === 0;
this.createRequirementRequestActive = true;
return this.$apollo
.mutate({
......@@ -261,9 +287,13 @@ export default {
})
.then(({ data }) => {
if (!data.createRequirement.errors.length) {
this.showCreateForm = false;
this.$apollo.queries.requirements.refetch();
this.openedCount += 1;
if (reloadPage) {
visitUrl(this.requirementsWebUrl);
} else {
this.showCreateForm = false;
this.$apollo.queries.requirements.refetch();
this.openedCount += 1;
}
} else {
throw new Error(`Error creating a requirement`);
}
......@@ -348,22 +378,24 @@ export default {
<template>
<div class="requirements-list-container">
<requirement-form
v-if="showCreateForm"
:requirement-request-active="createRequirementRequestActive"
@save="handleNewRequirementSave"
@cancel="handleNewRequirementCancel"
/>
<requirements-empty-state
v-if="requirementsListEmpty"
v-if="requirementsListEmpty && !showCreateForm"
:filter-by="filterBy"
:empty-state-path="emptyStatePath"
:requirements-count="requirementsCount"
@clickNewRequirement="handleNewRequirementClick"
/>
<requirements-loading
v-show="requirementsListLoading"
:filter-by="filterBy"
:current-tab-count="totalRequirements"
:current-page="currentPage"
/>
<requirement-form
v-if="showCreateForm"
:requirement-request-active="createRequirementRequestActive"
@save="handleNewRequirementSave"
@cancel="handleNewRequirementCancel"
:requirements-count="requirementsCount"
/>
<ul
v-if="!requirementsListLoading && !requirementsListEmpty"
......
......@@ -45,24 +45,28 @@ export default () => {
emptyStatePath,
opened,
archived,
all,
requirementsWebUrl,
} = el.dataset;
const stateFilterBy = filterBy ? FilterState[filterBy] : FilterState.opened;
const OPENED = parseInt(opened, 10);
const ARCHIVED = parseInt(archived, 10);
const ALL = parseInt(all, 10);
return {
filterBy: stateFilterBy,
requirementsCount: {
OPENED,
ARCHIVED,
ALL: OPENED + ARCHIVED,
ALL,
},
page,
prev,
next,
emptyStatePath,
projectPath,
requirementsWebUrl,
};
},
render(createElement) {
......@@ -75,6 +79,7 @@ export default () => {
prev: this.prev,
next: this.next,
emptyStatePath: this.emptyStatePath,
requirementsWebUrl: this.requirementsWebUrl,
},
});
},
......
......@@ -3,10 +3,22 @@
- page_context_word = type.to_s.humanize(capitalize: false)
- @content_class = 'requirements-container'
-# We'd prefer to have following declarations be part of
-# helpers in some way but given that they're very frontend-centeric,
-# keeping them in HAML view makes more sense.
- page_size = 20
- ignore_page_params = ['next', 'prev', 'page']
- requirements_count = Hash.new(0).merge(@project.requirements.counts_by_state)
- total_requirements = requirements_count['opened'] + requirements_count['archived']
- is_open_tab = params[:state].nil? || params[:state] == 'opened'
- if is_open_tab
- current_tab_count = requirements_count['opened'] > page_size ? page_size : requirements_count['opened']
- elsif params[:state] == 'archived'
- current_tab_count = requirements_count['archived'] > page_size ? page_size : requirements_count['archived']
- else
- current_tab_count = total_requirements > page_size ? page_size : total_requirements
.top-area
%ul.nav-links.mobile-separator.requirements-state-filters.js-requirements-state-filters
%li{ class: active_when(is_open_tab) }>
......@@ -22,7 +34,7 @@
%li{ class: active_when(params[:state] == 'all') }>
= link_to page_filter_path(state: 'all', without: ignore_page_params), id: 'state-all', title: (_("Show all %{issuable_type}.") % { issuable_type: page_context_word }), data: { state: 'all' } do
= _('All')
%span.badge.badge-pill.js-all-count= requirements_count['opened'] + requirements_count['archived']
%span.badge.badge-pill.js-all-count= total_requirements
.nav-controls
- if is_open_tab
......@@ -36,6 +48,21 @@
project_path: @project.full_path,
opened: requirements_count['opened'],
archived: requirements_count['archived'],
all: total_requirements,
requirements_web_url: project_requirements_path(@project),
empty_state_path: image_path('illustrations/empty-state/empty-requirements-lg.svg') } }
.gl-spinner-container.mt-3
%span.align-text-bottom.gl-spinner.gl-spinner-orange.gl-spinner-md{ aria: { label: _('Loading'), hidden: 'true' } }
- if current_tab_count == 0
-# Show regular spinner only when there will be no
-# requirements to show for current tab.
.gl-spinner-container.mt-3
%span.align-text-bottom.gl-spinner.gl-spinner-orange.gl-spinner-md{ aria: { label: _('Loading'), hidden: 'true' } }
- else
-# Following block shows skeleton loading same as mounted Vue app so while
-# app is being loaded and initialized, user continues to see skeleton loading.
.requirements-list-container
%ul.content-list.issuable-list.issues-list.requirements-list-loading
- Array.new(current_tab_count).each do |i|
%li.issue.requirement
.animation-container.pt-2
.skeleton-line-1
.skeleton-line-2
......@@ -69,6 +69,13 @@ describe 'Requirements list', :js do
end
end
it 'disables new requirement button while create form is open' do
page.within('.nav-controls') do
find('button.js-new-requirement').click
expect(find('button.js-new-requirement')[:disabled]).to eq "true"
end
end
it 'creates new requirement' do
requirement_title = 'Foobar'
......
import { shallowMount } from '@vue/test-utils';
import { GlEmptyState } from '@gitlab/ui';
import { GlEmptyState, GlDeprecatedButton } from '@gitlab/ui';
import RequirementsEmptyState from 'ee/requirements/components/requirements_empty_state.vue';
import { FilterState } from 'ee/requirements/constants';
......@@ -12,7 +12,13 @@ const createComponent = (
propsData: {
filterBy,
emptyStatePath,
requirementsCount: {
OPENED: 0,
ARCHIVED: 0,
ALL: 0,
},
},
stubs: { GlEmptyState },
});
describe('RequirementsEmptyState', () => {
......@@ -28,31 +34,99 @@ describe('RequirementsEmptyState', () => {
describe('computed', () => {
describe('emptyStateTitle', () => {
it('returns string "There are no open requirements" when value of `filterBy` prop is "OPENED"', () => {
expect(wrapper.vm.emptyStateTitle).toBe('There are no open requirements');
it('returns string "There are no open requirements" when value of `filterBy` prop is "OPENED" and project has some requirements', () => {
wrapper.setProps({
requirementsCount: {
OPENED: 0,
ARCHIVED: 2,
ALL: 2,
},
});
return wrapper.vm.$nextTick(() => {
expect(wrapper.vm.emptyStateTitle).toBe('There are no open requirements');
});
});
it('returns string "There are no archived requirements" when value of `filterBy` prop is "ARCHIVED"', () => {
it('returns string "There are no archived requirements" when value of `filterBy` prop is "ARCHIVED" and project has some requirements', () => {
wrapper.setProps({
filterBy: FilterState.archived,
requirementsCount: {
OPENED: 2,
ARCHIVED: 0,
ALL: 2,
},
});
return wrapper.vm.$nextTick(() => {
expect(wrapper.vm.emptyStateTitle).toBe('There are no archived requirements');
});
});
it('returns a generic string when project has no requirements', () => {
expect(wrapper.vm.emptyStateTitle).toBe(
'Requirements allow you to create criteria to check your products against.',
);
});
});
describe('emptyStateDescription', () => {
it('returns a generic string when project has no requirements', () => {
expect(wrapper.vm.emptyStateDescription).toBe(
'Requirements can be based on users, stakeholders, system, software or anything else you find important to capture.',
);
});
it('returns a null when project has some requirements', () => {
wrapper.setProps({
requirementsCount: {
OPENED: 2,
ARCHIVED: 0,
ALL: 2,
},
});
return wrapper.vm.$nextTick(() => {
expect(wrapper.vm.emptyStateDescription).toBeNull();
});
});
});
});
describe('template', () => {
it('renders empty state element', () => {
const emptyStateEl = wrapper.find(GlEmptyState);
const emptyStateEl = wrapper.find('.empty-state .svg-content img');
expect(emptyStateEl.exists()).toBe(true);
expect(emptyStateEl.props('title')).toBe('There are no open requirements');
expect(emptyStateEl.attributes('svgpath')).toBe(
expect(emptyStateEl.attributes('alt')).toBe(
'Requirements allow you to create criteria to check your products against.',
);
expect(emptyStateEl.attributes('src')).toBe(
'/assets/illustrations/empty-state/requirements.svg',
);
});
it('renders new requirement button when project has no requirements', () => {
const newReqButton = wrapper.find(GlDeprecatedButton);
expect(newReqButton.exists()).toBe(true);
expect(newReqButton.text()).toBe('New requirement');
});
it('does not render new requirement button when project some requirements', () => {
wrapper.setProps({
requirementsCount: {
OPENED: 2,
ARCHIVED: 0,
ALL: 2,
},
});
return wrapper.vm.$nextTick(() => {
const newReqButton = wrapper.find(GlDeprecatedButton);
expect(newReqButton.exists()).toBe(false);
});
});
});
});
import { shallowMount } from '@vue/test-utils';
import { GlSkeletonLoading } from '@gitlab/ui';
import { GlSkeletonLoading, GlLoadingIcon } from '@gitlab/ui';
import RequirementsLoading from 'ee/requirements/components/requirements_loading.vue';
import { FilterState, mockRequirementsCount } from '../mock_data';
jest.mock('ee/requirements/constants', () => ({
DEFAULT_PAGE_SIZE: 2,
FilterState: {
opened: 'OPENED',
archived: 'ARCHIVED',
all: 'ALL',
},
}));
const createComponent = ({
filterBy = FilterState.opened,
currentTabCount = mockRequirementsCount.OPENED,
requirementsCount = mockRequirementsCount,
currentPage = 1,
} = {}) =>
shallowMount(RequirementsLoading, {
propsData: {
filterBy,
currentTabCount,
currentPage,
requirementsCount,
},
});
......@@ -58,7 +63,11 @@ describe('RequirementsLoading', () => {
it('returns value DEFAULT_PAGE_SIZE when current page is the last page total requirements are less than DEFAULT_PAGE_SIZE', () => {
wrapper.setProps({
currentPage: 1,
currentTabCount: 1,
requirementsCount: {
OPENED: 1,
ARCHIVED: 0,
ALL: 2,
},
});
return wrapper.vm.$nextTick(() => {
......@@ -69,11 +78,26 @@ describe('RequirementsLoading', () => {
});
describe('template', () => {
it('renders gl-skeleton-loading component based on loaderCount', () => {
it('renders gl-skeleton-loading component project has some requirements and current tab has requirements to show', () => {
const loaders = wrapper.find('.requirements-list-loading').findAll(GlSkeletonLoading);
expect(loaders.length).toBe(2);
expect(loaders.at(0).props('lines')).toBe(2);
});
it('renders gl-loading-icon component project has no requirements and current tab has nothing to show', () => {
wrapper.setProps({
requirementsCount: {
OPENED: 0,
ARCHIVED: 0,
ALL: 0,
},
});
return wrapper.vm.$nextTick(() => {
expect(wrapper.find('.requirements-list-loading').exists()).toBe(false);
expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
});
});
});
});
......@@ -3,6 +3,7 @@ import { shallowMount } from '@vue/test-utils';
import { GlPagination } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import createFlash from '~/flash';
import { visitUrl } from '~/lib/utils/url_utility';
import RequirementsRoot from 'ee/requirements/components/requirements_root.vue';
import RequirementsLoading from 'ee/requirements/components/requirements_loading.vue';
......@@ -30,6 +31,10 @@ jest.mock('ee/requirements/constants', () => ({
}));
jest.mock('~/flash');
jest.mock('~/lib/utils/url_utility', () => ({
...jest.requireActual('~/lib/utils/url_utility'),
visitUrl: jest.fn(),
}));
const createComponent = ({
projectPath = 'gitlab-org/gitlab-shell',
......@@ -38,6 +43,7 @@ const createComponent = ({
showCreateRequirement = false,
emptyStatePath = '/assets/illustrations/empty-state/requirements.svg',
loading = false,
requirementsWebUrl = '/gitlab-org/gitlab-shell/-/requirements',
} = {}) =>
shallowMount(RequirementsRoot, {
propsData: {
......@@ -46,6 +52,7 @@ const createComponent = ({
requirementsCount,
showCreateRequirement,
emptyStatePath,
requirementsWebUrl,
},
mocks: {
$apollo: {
......@@ -267,6 +274,34 @@ describe('RequirementsRoot', () => {
});
});
describe('enableOrDisableNewRequirement', () => {
it('disables new requirement button when called with param `{ disable: true }`', () => {
wrapper.vm.enableOrDisableNewRequirement({
disable: true,
});
return wrapper.vm.$nextTick(() => {
const newReqButton = document.querySelector('.js-new-requirement');
expect(newReqButton.getAttribute('disabled')).toBe('disabled');
expect(newReqButton.classList.contains('disabled')).toBe(true);
});
});
it('enables new requirement button when called with param `{ disable: false }`', () => {
wrapper.vm.enableOrDisableNewRequirement({
disable: false,
});
return wrapper.vm.$nextTick(() => {
const newReqButton = document.querySelector('.js-new-requirement');
expect(newReqButton.getAttribute('disabled')).toBeNull();
expect(newReqButton.classList.contains('disabled')).toBe(false);
});
});
});
describe('handleNewRequirementClick', () => {
it('sets `showCreateForm` prop to `true`', () => {
wrapper.vm.handleNewRequirementClick();
......@@ -322,6 +357,22 @@ describe('RequirementsRoot', () => {
);
});
it('calls `visitUrl` when project has no requirements and request is successful', () => {
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockMutationResult);
wrapper.setProps({
requirementsCount: {
OPENED: 0,
ARCHIVED: 0,
ALL: 0,
},
});
return wrapper.vm.handleNewRequirementSave('foo').then(() => {
expect(visitUrl).toHaveBeenCalledWith('/gitlab-org/gitlab-shell/-/requirements');
});
});
it('sets `showCreateForm` and `createRequirementRequestActive` props to `false` and calls `$apollo.queries.requirements.refetch()` when request is successful', () => {
jest
.spyOn(wrapper.vm.$apollo, 'mutate')
......@@ -587,6 +638,16 @@ describe('RequirementsRoot', () => {
});
});
it('does not render requirement-empty-state component when `showCreateForm` prop is `true`', () => {
wrapper.setData({
showCreateForm: true,
});
return wrapper.vm.$nextTick(() => {
expect(wrapper.find(RequirementsEmptyState).exists()).toBe(false);
});
});
it('renders requirement items for all the requirements', () => {
wrapper.setData({
requirements: {
......
......@@ -17184,6 +17184,12 @@ msgstr ""
msgid "Requirements"
msgstr ""
msgid "Requirements allow you to create criteria to check your products against."
msgstr ""
msgid "Requirements can be based on users, stakeholders, system, software or anything else you find important to capture."
msgstr ""
msgid "Requires approval from %{names}."
msgid_plural "Requires %{count} more approvals from %{names}."
msgstr[0] ""
......
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