Commit d2baf7a7 authored by Martin Wortschack's avatar Martin Wortschack

Merge branch 'kp-add-toast-messages-in-requirements' into 'master'

Add support for toast messages on user actions in Requirements

See merge request gitlab-org/gitlab!29472
parents 1cf18804 14954c41
<script> <script>
import * as Sentry from '@sentry/browser'; import * as Sentry from '@sentry/browser';
import { GlPagination } from '@gitlab/ui'; import { GlPagination } from '@gitlab/ui';
import { __ } from '~/locale'; import { __, sprintf } from '~/locale';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { urlParamsToObject } from '~/lib/utils/common_utils'; import { urlParamsToObject } from '~/lib/utils/common_utils';
import { updateHistory, setUrlParams, visitUrl } from '~/lib/utils/url_utility'; import { updateHistory, setUrlParams, visitUrl } from '~/lib/utils/url_utility';
...@@ -149,18 +149,41 @@ export default { ...@@ -149,18 +149,41 @@ export default {
requirementsListEmpty() { requirementsListEmpty() {
return !this.$apollo.queries.requirements.loading && !this.requirements.list.length; return !this.$apollo.queries.requirements.loading && !this.requirements.list.length;
}, },
totalRequirements() { /**
* We want to ensure that count `0` is prioritized
* over `this.requirements.count` (GraphQL) or `this.requirementsCount` (HAML prop)
* as both of them are invalid once user does archive/reopen actions.
* this is a technical debt that we want to clean up once mutations support
* `requirementStatesCount` connection.
*/
totalRequirementsForCurrentTab() {
if (this.filterBy === FilterState.opened) {
return this.openedCount === 0
? 0
: this.requirements.count.OPENED || this.requirementsCount.OPENED;
} else if (this.filterBy === FilterState.archived) {
return this.archivedCount === 0
? 0
: this.requirements.count.ARCHIVED || this.requirementsCount.ARCHIVED;
}
return this.requirements.count[this.filterBy] || this.requirementsCount[this.filterBy]; return this.requirements.count[this.filterBy] || this.requirementsCount[this.filterBy];
}, },
showEmptyState() {
return (
(this.requirementsListEmpty && !this.showCreateForm) || !this.totalRequirementsForCurrentTab
);
},
showPaginationControls() { showPaginationControls() {
return this.totalRequirements > DEFAULT_PAGE_SIZE && !this.requirementsListEmpty; return this.totalRequirementsForCurrentTab > DEFAULT_PAGE_SIZE && !this.requirementsListEmpty;
}, },
prevPage() { prevPage() {
return Math.max(this.currentPage - 1, 0); return Math.max(this.currentPage - 1, 0);
}, },
nextPage() { nextPage() {
const nextPage = this.currentPage + 1; const nextPage = this.currentPage + 1;
return nextPage > Math.ceil(this.totalRequirements / DEFAULT_PAGE_SIZE) ? null : nextPage; return nextPage > Math.ceil(this.totalRequirementsForCurrentTab / DEFAULT_PAGE_SIZE)
? null
: nextPage;
}, },
}, },
watch: { watch: {
...@@ -273,7 +296,7 @@ export default { ...@@ -273,7 +296,7 @@ export default {
this.showUpdateFormForRequirement = iid; this.showUpdateFormForRequirement = iid;
}, },
handleNewRequirementSave(title) { handleNewRequirementSave(title) {
const reloadPage = this.totalRequirements === 0; const reloadPage = this.totalRequirementsForCurrentTab === 0;
this.createRequirementRequestActive = true; this.createRequirementRequestActive = true;
return this.$apollo return this.$apollo
.mutate({ .mutate({
...@@ -293,6 +316,11 @@ export default { ...@@ -293,6 +316,11 @@ export default {
this.showCreateForm = false; this.showCreateForm = false;
this.$apollo.queries.requirements.refetch(); this.$apollo.queries.requirements.refetch();
this.openedCount += 1; this.openedCount += 1;
this.$toast.show(
sprintf(__('Requirement %{reference} has been added'), {
reference: `REQ-${data.createRequirement.requirement.iid}`,
}),
);
} }
} else { } else {
throw new Error(`Error creating a requirement`); throw new Error(`Error creating a requirement`);
...@@ -318,6 +346,11 @@ export default { ...@@ -318,6 +346,11 @@ export default {
.then(({ data }) => { .then(({ data }) => {
if (!data.updateRequirement.errors.length) { if (!data.updateRequirement.errors.length) {
this.showUpdateFormForRequirement = 0; this.showUpdateFormForRequirement = 0;
this.$toast.show(
sprintf(__('Requirement %{reference} has been updated'), {
reference: `REQ-${data.updateRequirement.requirement.iid}`,
}),
);
} else { } else {
throw new Error(`Error updating a requirement`); throw new Error(`Error updating a requirement`);
} }
...@@ -337,17 +370,24 @@ export default { ...@@ -337,17 +370,24 @@ export default {
}).then(({ data }) => { }).then(({ data }) => {
if (!data.updateRequirement.errors.length) { if (!data.updateRequirement.errors.length) {
this.stateChangeRequestActiveFor = 0; this.stateChangeRequestActiveFor = 0;
let toastMessage;
if (params.state === FilterState.opened) {
this.openedCount += 1;
this.archivedCount -= 1;
toastMessage = sprintf(__('Requirement %{reference} has been reopened'), {
reference: `REQ-${data.updateRequirement.requirement.iid}`,
});
} else {
this.openedCount -= 1;
this.archivedCount += 1;
toastMessage = sprintf(__('Requirement %{reference} has been archived'), {
reference: `REQ-${data.updateRequirement.requirement.iid}`,
});
}
this.$toast.show(toastMessage);
} else { } else {
throw new Error(`Error archiving a requirement`); throw new Error(`Error archiving a requirement`);
} }
if (params.state === FilterState.opened) {
this.openedCount += 1;
this.archivedCount -= 1;
} else {
this.openedCount -= 1;
this.archivedCount += 1;
}
}); });
}, },
handleUpdateRequirementCancel() { handleUpdateRequirementCancel() {
...@@ -385,7 +425,7 @@ export default { ...@@ -385,7 +425,7 @@ export default {
@cancel="handleNewRequirementCancel" @cancel="handleNewRequirementCancel"
/> />
<requirements-empty-state <requirements-empty-state
v-if="requirementsListEmpty && !showCreateForm" v-if="showEmptyState"
:filter-by="filterBy" :filter-by="filterBy"
:empty-state-path="emptyStatePath" :empty-state-path="emptyStatePath"
:requirements-count="requirementsCount" :requirements-count="requirementsCount"
......
...@@ -2,5 +2,8 @@ mutation createRequirement($createRequirementInput: CreateRequirementInput!) { ...@@ -2,5 +2,8 @@ mutation createRequirement($createRequirementInput: CreateRequirementInput!) {
createRequirement(input: $createRequirementInput) { createRequirement(input: $createRequirementInput) {
clientMutationId clientMutationId
errors errors
requirement {
iid
}
} }
} }
import Vue from 'vue'; import Vue from 'vue';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import { GlToast } from '@gitlab/ui';
import { defaultDataIdFromObject } from 'apollo-cache-inmemory'; import { defaultDataIdFromObject } from 'apollo-cache-inmemory';
import createDefaultClient from '~/lib/graphql'; import createDefaultClient from '~/lib/graphql';
...@@ -8,6 +9,7 @@ import RequirementsRoot from './components/requirements_root.vue'; ...@@ -8,6 +9,7 @@ import RequirementsRoot from './components/requirements_root.vue';
import { FilterState } from './constants'; import { FilterState } from './constants';
Vue.use(VueApollo); Vue.use(VueApollo);
Vue.use(GlToast);
export default () => { export default () => {
const el = document.getElementById('js-requirements-app'); const el = document.getElementById('js-requirements-app');
......
- return unless Feature.enabled?(:requirements_management, project) - return unless Feature.enabled?(:requirements_management, project)
- return unless can?(current_user, :read_requirement, project)
- requirements_count = Hash.new(0).merge(project.requirements.counts_by_state) - requirements_count = Hash.new(0).merge(project.requirements.counts_by_state)
- total_count = requirements_count['opened'] + requirements_count['archived'] - total_count = requirements_count['opened'] + requirements_count['archived']
......
...@@ -93,6 +93,15 @@ describe 'Project navbar' do ...@@ -93,6 +93,15 @@ describe 'Project navbar' do
context 'when requirements is available' do context 'when requirements is available' do
before do before do
stub_licensed_features(requirements: true) stub_licensed_features(requirements: true)
stub_feature_flags(requirements_management: { enabled: true, thing: project })
insert_after_nav_item(
_('Merge Requests'),
new_nav_item: {
nav_item: _('Requirements'),
nav_sub_items: [_('List')]
}
)
visit project_path(project) visit project_path(project)
end end
......
...@@ -25,6 +25,7 @@ describe 'Requirements list', :js do ...@@ -25,6 +25,7 @@ describe 'Requirements list', :js do
before do before do
stub_licensed_features(requirements: true) stub_licensed_features(requirements: true)
stub_feature_flags(requirements_management: { enabled: true, thing: project })
project.add_maintainer(user) project.add_maintainer(user)
sign_in(user) sign_in(user)
......
...@@ -36,6 +36,10 @@ jest.mock('~/lib/utils/url_utility', () => ({ ...@@ -36,6 +36,10 @@ jest.mock('~/lib/utils/url_utility', () => ({
visitUrl: jest.fn(), visitUrl: jest.fn(),
})); }));
const $toast = {
show: jest.fn(),
};
const createComponent = ({ const createComponent = ({
projectPath = 'gitlab-org/gitlab-shell', projectPath = 'gitlab-org/gitlab-shell',
filterBy = FilterState.opened, filterBy = FilterState.opened,
...@@ -67,6 +71,7 @@ const createComponent = ({ ...@@ -67,6 +71,7 @@ const createComponent = ({
}, },
mutate: jest.fn(), mutate: jest.fn(),
}, },
$toast,
}, },
}); });
...@@ -92,9 +97,22 @@ describe('RequirementsRoot', () => { ...@@ -92,9 +97,22 @@ describe('RequirementsRoot', () => {
}); });
describe('computed', () => { describe('computed', () => {
describe('totalRequirements', () => { describe('totalRequirementsForCurrentTab', () => {
it('returns number representing total requirements for current tab', () => { it('returns number representing total requirements for current tab', () => {
expect(wrapper.vm.totalRequirements).toBe(mockRequirementsCount.OPENED); expect(wrapper.vm.totalRequirementsForCurrentTab).toBe(mockRequirementsCount.OPENED);
});
it('returns 0 when `openedCount` is 0 and filterBy represents opened tab', () => {
wrapper.setProps({
filterBy: FilterState.opened,
});
wrapper.setData({
openedCount: 0,
});
return wrapper.vm.$nextTick(() => {
expect(wrapper.vm.totalRequirementsForCurrentTab).toBe(0);
});
}); });
}); });
...@@ -323,6 +341,9 @@ describe('RequirementsRoot', () => { ...@@ -323,6 +341,9 @@ describe('RequirementsRoot', () => {
data: { data: {
createRequirement: { createRequirement: {
errors: [], errors: [],
requirement: {
iid: '1',
},
}, },
}, },
}; };
...@@ -388,6 +409,14 @@ describe('RequirementsRoot', () => { ...@@ -388,6 +409,14 @@ describe('RequirementsRoot', () => {
}); });
}); });
it('calls `$toast.show` with string "Requirement added successfully" when request is successful', () => {
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockMutationResult);
return wrapper.vm.handleNewRequirementSave('foo').then(() => {
expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('Requirement REQ-1 has been added');
});
});
it('sets `createRequirementRequestActive` prop to `false` and calls `createFlash` when `$apollo.mutate` request fails', () => { it('sets `createRequirementRequestActive` prop to `false` and calls `createFlash` when `$apollo.mutate` request fails', () => {
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockReturnValue(Promise.reject(new Error())); jest.spyOn(wrapper.vm.$apollo, 'mutate').mockReturnValue(Promise.reject(new Error()));
...@@ -442,6 +471,21 @@ describe('RequirementsRoot', () => { ...@@ -442,6 +471,21 @@ describe('RequirementsRoot', () => {
}); });
}); });
it('calls `$toast.show` with string "Requirement updated successfully" when request is successful', () => {
jest.spyOn(wrapper.vm, 'updateRequirement').mockResolvedValue(mockUpdateMutationResult);
return wrapper.vm
.handleUpdateRequirementSave({
iid: '1',
title: 'foo',
})
.then(() => {
expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(
'Requirement REQ-1 has been updated',
);
});
});
it('sets `createRequirementRequestActive` prop to `false` when request fails', () => { it('sets `createRequirementRequestActive` prop to `false` when request fails', () => {
jest.spyOn(wrapper.vm, 'updateRequirement').mockRejectedValue(new Error()); jest.spyOn(wrapper.vm, 'updateRequirement').mockRejectedValue(new Error());
...@@ -542,6 +586,19 @@ describe('RequirementsRoot', () => { ...@@ -542,6 +586,19 @@ describe('RequirementsRoot', () => {
}); });
}); });
it('calls `$toast.show` with string "Requirement has been reopened" when `params.state` is "OPENED" and request is successful', () => {
return wrapper.vm
.handleRequirementStateChange({
iid: '1',
state: FilterState.opened,
})
.then(() => {
expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(
'Requirement REQ-1 has been reopened',
);
});
});
it('decrements `openedCount` by 1 and increments `archivedCount` by 1 when `params.state` is "ARCHIVED"', () => { it('decrements `openedCount` by 1 and increments `archivedCount` by 1 when `params.state` is "ARCHIVED"', () => {
wrapper.setData({ wrapper.setData({
openedCount: 1, openedCount: 1,
...@@ -558,6 +615,19 @@ describe('RequirementsRoot', () => { ...@@ -558,6 +615,19 @@ describe('RequirementsRoot', () => {
expect(wrapper.vm.archivedCount).toBe(2); expect(wrapper.vm.archivedCount).toBe(2);
}); });
}); });
it('calls `$toast.show` with string "Requirement has been archived" when `params.state` is "ARCHIVED" and request is successful', () => {
return wrapper.vm
.handleRequirementStateChange({
iid: '1',
state: FilterState.archived,
})
.then(() => {
expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(
'Requirement REQ-1 has been archived',
);
});
});
}); });
describe('handleUpdateRequirementCancel', () => { describe('handleUpdateRequirementCancel', () => {
......
...@@ -17202,6 +17202,18 @@ msgstr "" ...@@ -17202,6 +17202,18 @@ msgstr ""
msgid "Requirement" msgid "Requirement"
msgstr "" msgstr ""
msgid "Requirement %{reference} has been added"
msgstr ""
msgid "Requirement %{reference} has been archived"
msgstr ""
msgid "Requirement %{reference} has been reopened"
msgstr ""
msgid "Requirement %{reference} has been updated"
msgstr ""
msgid "Requirement title cannot have more than %{limit} characters." msgid "Requirement title cannot have more than %{limit} characters."
msgstr "" msgstr ""
......
# frozen_string_literal: true # frozen_string_literal: true
RSpec.shared_context 'project navbar structure' do RSpec.shared_context 'project navbar structure' do
let(:requirements_nav_item) do
{
nav_item: _('Requirements'),
nav_sub_items: [_('List')]
}
end
let(:analytics_nav_item) do let(:analytics_nav_item) do
{ {
nav_item: _('Analytics'), nav_item: _('Analytics'),
...@@ -56,7 +49,6 @@ RSpec.shared_context 'project navbar structure' do ...@@ -56,7 +49,6 @@ RSpec.shared_context 'project navbar structure' do
nav_item: _('Merge Requests'), nav_item: _('Merge Requests'),
nav_sub_items: [] nav_sub_items: []
}, },
(requirements_nav_item if Gitlab.ee?),
{ {
nav_item: _('CI / CD'), nav_item: _('CI / CD'),
nav_sub_items: [ nav_sub_items: [
......
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