Commit a023cbd4 authored by Simon Knox's avatar Simon Knox

Merge branch 'add-swimlanes-sidebar-subscription' into 'master'

Add notification subscription feature to swimlanes sidebar

See merge request gitlab-org/gitlab!47253
parents 8b293d23 529831ec
......@@ -18,7 +18,7 @@ export default {
};
},
computed: {
...mapGetters({ issue: 'activeIssue' }),
...mapGetters({ issue: 'activeIssue', projectPathForActiveIssue: 'projectPathForActiveIssue' }),
hasDueDate() {
return this.issue.dueDate != null;
},
......@@ -36,10 +36,6 @@ export default {
return dateInWords(this.parsedDueDate, true);
},
projectPath() {
const referencePath = this.issue.referencePath || '';
return referencePath.slice(0, referencePath.indexOf('#'));
},
},
methods: {
...mapActions(['setActiveIssueDueDate']),
......@@ -53,7 +49,7 @@ export default {
try {
const dueDate = date ? formatDate(date, 'yyyy-mm-dd') : null;
await this.setActiveIssueDueDate({ dueDate, projectPath: this.projectPath });
await this.setActiveIssueDueDate({ dueDate, projectPath: this.projectPathForActiveIssue });
} catch (e) {
createFlash({ message: this.$options.i18n.updateDueDateError });
} finally {
......
......@@ -21,9 +21,9 @@ export default {
},
inject: ['labelsFetchPath', 'labelsManagePath', 'labelsFilterBasePath'],
computed: {
...mapGetters({ issue: 'activeIssue' }),
...mapGetters(['activeIssue', 'projectPathForActiveIssue']),
selectedLabels() {
const { labels = [] } = this.issue;
const { labels = [] } = this.activeIssue;
return labels.map(label => ({
...label,
......@@ -31,17 +31,13 @@ export default {
}));
},
issueLabels() {
const { labels = [] } = this.issue;
const { labels = [] } = this.activeIssue;
return labels.map(label => ({
...label,
scoped: isScopedLabel(label),
}));
},
projectPath() {
const { referencePath = '' } = this.issue;
return referencePath.slice(0, referencePath.indexOf('#'));
},
},
methods: {
...mapActions(['setActiveIssueLabels']),
......@@ -55,7 +51,7 @@ export default {
.filter(label => !payload.find(selected => selected.id === label.id))
.map(label => label.id);
const input = { addLabelIds, removeLabelIds, projectPath: this.projectPath };
const input = { addLabelIds, removeLabelIds, projectPath: this.projectPathForActiveIssue };
await this.setActiveIssueLabels(input);
} catch (e) {
createFlash({ message: __('An error occurred while updating labels.') });
......@@ -68,7 +64,7 @@ export default {
try {
const removeLabelIds = [getIdFromGraphQLId(id)];
const input = { removeLabelIds, projectPath: this.projectPath };
const input = { removeLabelIds, projectPath: this.projectPathForActiveIssue };
await this.setActiveIssueLabels(input);
} catch (e) {
createFlash({ message: __('An error occurred when removing the label.') });
......
<script>
import { mapGetters, mapActions } from 'vuex';
import { GlToggle } from '@gitlab/ui';
import createFlash from '~/flash';
import { __, s__ } from '~/locale';
export default {
i18n: {
header: {
title: __('Notifications'),
/* Any change to subscribeDisabledDescription
must be reflected in app/helpers/notifications_helper.rb */
subscribeDisabledDescription: __(
'Notifications have been disabled by the project or group owner',
),
},
updateSubscribedErrorMessage: s__(
'IssueBoards|An error occurred while setting notifications status.',
),
},
components: {
GlToggle,
},
data() {
return {
loading: false,
};
},
computed: {
...mapGetters(['activeIssue', 'projectPathForActiveIssue']),
notificationText() {
return this.activeIssue.emailsDisabled
? this.$options.i18n.header.subscribeDisabledDescription
: this.$options.i18n.header.title;
},
},
methods: {
...mapActions(['setActiveIssueSubscribed']),
async handleToggleSubscription() {
this.loading = true;
try {
await this.setActiveIssueSubscribed({
subscribed: !this.activeIssue.subscribed,
projectPath: this.projectPathForActiveIssue,
});
} catch (error) {
createFlash({ message: this.$options.i18n.updateSubscribedErrorMessage });
} finally {
this.loading = false;
}
},
},
};
</script>
<template>
<div
class="gl-display-flex gl-align-items-center gl-justify-content-space-between"
data-testid="sidebar-notifications"
>
<span data-testid="notification-header-text"> {{ notificationText }} </span>
<gl-toggle
v-if="!activeIssue.emailsDisabled"
:value="activeIssue.subscribed"
:is-loading="loading"
data-testid="notification-subscribe-toggle"
@change="handleToggleSubscription"
/>
</div>
</template>
mutation issueSetSubscription($input: IssueSetSubscriptionInput!) {
issueSetSubscription(input: $input) {
issue {
subscribed
}
errors
}
}
......@@ -24,6 +24,7 @@ import destroyBoardListMutation from '../queries/board_list_destroy.mutation.gra
import issueCreateMutation from '../queries/issue_create.mutation.graphql';
import issueSetLabels from '../queries/issue_set_labels.mutation.graphql';
import issueSetDueDate from '../queries/issue_set_due_date.mutation.graphql';
import issueSetSubscriptionMutation from '../graphql/mutations/issue_set_subscription.mutation.graphql';
const notImplemented = () => {
/* eslint-disable-next-line @gitlab/require-i18n-strings */
......@@ -423,6 +424,29 @@ export default {
});
},
setActiveIssueSubscribed: async ({ commit, getters }, input) => {
const { data } = await gqlClient.mutate({
mutation: issueSetSubscriptionMutation,
variables: {
input: {
iid: String(getters.activeIssue.iid),
projectPath: input.projectPath,
subscribedState: input.subscribed,
},
},
});
if (data.issueSetSubscription?.errors?.length > 0) {
throw new Error(data.issueSetSubscription.errors);
}
commit(types.UPDATE_ISSUE_BY_ID, {
issueId: getters.activeIssue.id,
prop: 'subscribed',
value: data.issueSetSubscription.issue.subscribed,
});
},
fetchBacklog: () => {
notImplemented();
},
......
......@@ -24,6 +24,11 @@ export default {
return state.issues[state.activeId] || {};
},
projectPathForActiveIssue: (_, getters) => {
const referencePath = getters.activeIssue.referencePath || '';
return referencePath.slice(0, referencePath.indexOf('#'));
},
getListByLabelId: state => labelId => {
return find(state.boardLists, l => l.label?.id === labelId);
},
......
......@@ -67,6 +67,7 @@ module NotificationsHelper
when :custom
_('You will only receive notifications for the events you choose')
when :owner_disabled
# Any change must be reflected in board_sidebar_subscription.vue
_('Notifications have been disabled by the project or group owner')
end
end
......
......@@ -11,6 +11,7 @@ import BoardSidebarTimeTracker from './sidebar/board_sidebar_time_tracker.vue';
import BoardSidebarWeightInput from './sidebar/board_sidebar_weight_input.vue';
import BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue';
import BoardSidebarDueDate from '~/boards/components/sidebar/board_sidebar_due_date.vue';
import BoardSidebarSubscription from '~/boards/components/sidebar/board_sidebar_subscription.vue';
export default {
headerHeight: `${contentTop()}px`,
......@@ -23,6 +24,7 @@ export default {
BoardSidebarWeightInput,
BoardSidebarLabelsSelect,
BoardSidebarDueDate,
BoardSidebarSubscription,
},
mixins: [glFeatureFlagsMixin()],
computed: {
......@@ -56,6 +58,7 @@ export default {
<board-sidebar-weight-input v-if="glFeatures.issueWeights" />
<board-sidebar-labels-select />
<board-sidebar-due-date />
<board-sidebar-subscription />
</template>
</gl-drawer>
</template>
......@@ -20,9 +20,9 @@ export default {
inject: ['groupId'],
computed: {
...mapState(['epics']),
...mapGetters({ getEpicById: 'getEpicById', issue: 'activeIssue' }),
...mapGetters(['activeIssue', 'getEpicById', 'projectPathForActiveIssue']),
storedEpic() {
const storedEpic = this.getEpicById(this.issue.epic?.id);
const storedEpic = this.getEpicById(this.activeIssue.epic?.id);
const epicId = getIdFromGraphQLId(storedEpic?.id);
return {
......@@ -30,10 +30,6 @@ export default {
id: Number(epicId),
};
},
projectPath() {
const { referencePath = '' } = this.issue;
return referencePath.slice(0, referencePath.indexOf('#'));
},
},
methods: {
...mapMutations({
......@@ -51,7 +47,7 @@ export default {
const epicId = selectedEpic?.id ? `gid://gitlab/Epic/${selectedEpic.id}` : null;
const input = {
epicId,
projectPath: this.projectPath,
projectPath: this.projectPathForActiveIssue,
};
try {
......@@ -62,7 +58,7 @@ export default {
}
debounceByAnimationFrame(() => {
this.updateIssueById({ issueId: this.issue.id, prop: 'epic', value: epic });
this.updateIssueById({ issueId: this.activeIssue.id, prop: 'epic', value: epic });
this.loading = false;
})();
} catch (e) {
......
......@@ -23,14 +23,10 @@ export default {
};
},
computed: {
...mapGetters({ issue: 'activeIssue' }),
...mapGetters({ issue: 'activeIssue', projectPathForActiveIssue: 'projectPathForActiveIssue' }),
hasWeight() {
return this.issue.weight > 0;
},
projectPath() {
const { referencePath = '' } = this.issue;
return referencePath.slice(0, referencePath.indexOf('#'));
},
},
watch: {
issue: {
......@@ -56,7 +52,7 @@ export default {
this.loading = true;
try {
await this.setActiveIssueWeight({ weight, projectPath: this.projectPath });
await this.setActiveIssueWeight({ weight, projectPath: this.projectPathForActiveIssue });
this.weight = weight;
} catch (e) {
this.weight = this.issue.weight;
......
......@@ -10,6 +10,7 @@ fragment IssueNode on Issue {
totalTimeSpent
humanTimeEstimate
humanTotalTimeSpent
emailsDisabled
weight
confidential
webUrl
......
......@@ -5,7 +5,7 @@ require 'spec_helper'
RSpec.describe 'epics swimlanes sidebar', :js do
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group, :public) }
let_it_be(:project) { create(:project, :public, group: group) }
let_it_be(:project, reload: true) { create(:project, :public, group: group) }
let_it_be(:board) { create(:board, project: project) }
let_it_be(:list) { create(:list, board: board, position: 0) }
......@@ -31,6 +31,47 @@ RSpec.describe 'epics swimlanes sidebar', :js do
wait_for_all_requests
end
context 'notifications subscription' do
it 'displays notifications toggle' do
click_first_issue_card
page.within('[data-testid="sidebar-notifications"]') do
expect(page).to have_selector('[data-testid="notification-subscribe-toggle"]')
expect(page).to have_content('Notifications')
expect(page).not_to have_content('Notifications have been disabled by the project or group owner')
end
end
it 'shows toggle as on then as off as user toggles to subscribe and unsubscribe' do
click_first_issue_card
toggle = find('[data-testid="notification-subscribe-toggle"]')
toggle.click
expect(toggle).to have_css("button.is-checked")
toggle.click
expect(toggle).not_to have_css("button.is-checked")
end
context 'when notifications have been disabled' do
before do
project.update_attribute(:emails_disabled, true)
end
it 'displays a message that notifications have been disabled' do
click_first_issue_card
page.within('[data-testid="sidebar-notifications"]') do
expect(page).not_to have_selector('[data-testid="notification-subscribe-toggle"]')
expect(page).to have_content('Notifications have been disabled by the project or group owner')
end
end
end
end
context 'time tracking' do
it 'displays time tracking feature with default message' do
click_first_issue_card
......
......@@ -24,6 +24,7 @@ describe('ee/BoardContentSidebar', () => {
'board-sidebar-weight-input': '<div></div>',
'board-sidebar-labels-select': '<div></div>',
'board-sidebar-due-date': '<div></div>',
'board-sidebar-subscription': '<div></div>',
},
});
};
......
......@@ -15101,6 +15101,9 @@ msgstr ""
msgid "IssueAnalytics|Weight"
msgstr ""
msgid "IssueBoards|An error occurred while setting notifications status."
msgstr ""
msgid "IssueBoards|Board"
msgstr ""
......
import Vuex from 'vuex';
import { mount, createLocalVue } from '@vue/test-utils';
import { GlToggle, GlLoadingIcon } from '@gitlab/ui';
import BoardSidebarSubscription from '~/boards/components/sidebar/board_sidebar_subscription.vue';
import * as types from '~/boards/stores/mutation_types';
import { createStore } from '~/boards/stores';
import { mockActiveIssue } from '../../mock_data';
import createFlash from '~/flash';
jest.mock('~/flash.js');
const localVue = createLocalVue();
localVue.use(Vuex);
describe('~/boards/components/sidebar/board_sidebar_subscription_spec.vue', () => {
let wrapper;
let store;
const findNotificationHeader = () => wrapper.find("[data-testid='notification-header-text']");
const findToggle = () => wrapper.find(GlToggle);
const findGlLoadingIcon = () => wrapper.find(GlLoadingIcon);
const createComponent = (activeIssue = { ...mockActiveIssue }) => {
store = createStore();
store.state.issues = { [activeIssue.id]: activeIssue };
store.state.activeId = activeIssue.id;
wrapper = mount(BoardSidebarSubscription, {
localVue,
store,
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
store = null;
jest.clearAllMocks();
});
describe('Board sidebar subscription component template', () => {
it('displays "notifications" heading', () => {
createComponent();
expect(findNotificationHeader().text()).toBe('Notifications');
});
it('renders toggle as "off" when currently not subscribed', () => {
createComponent();
expect(findToggle().exists()).toBe(true);
expect(findToggle().props('value')).toBe(false);
});
it('renders toggle as "on" when currently subscribed', () => {
createComponent({
...mockActiveIssue,
subscribed: true,
});
expect(findToggle().exists()).toBe(true);
expect(findToggle().props('value')).toBe(true);
});
describe('when notification emails have been disabled', () => {
beforeEach(() => {
createComponent({
...mockActiveIssue,
emailsDisabled: true,
});
});
it('displays a message that notification have been disabled', () => {
expect(findNotificationHeader().text()).toBe(
'Notifications have been disabled by the project or group owner',
);
});
it('does not render the toggle button', () => {
expect(findToggle().exists()).toBe(false);
});
});
});
describe('Board sidebar subscription component `behavior`', () => {
const mockSetActiveIssueSubscribed = subscribedState => {
jest.spyOn(wrapper.vm, 'setActiveIssueSubscribed').mockImplementation(async () => {
store.commit(types.UPDATE_ISSUE_BY_ID, {
issueId: mockActiveIssue.id,
prop: 'subscribed',
value: subscribedState,
});
});
};
it('subscribing to notification', async () => {
createComponent();
mockSetActiveIssueSubscribed(true);
expect(findGlLoadingIcon().exists()).toBe(false);
findToggle().trigger('click');
await wrapper.vm.$nextTick();
expect(findGlLoadingIcon().exists()).toBe(true);
expect(wrapper.vm.setActiveIssueSubscribed).toHaveBeenCalledWith({
subscribed: true,
projectPath: 'gitlab-org/test-subgroup/gitlab-test',
});
await wrapper.vm.$nextTick();
expect(findGlLoadingIcon().exists()).toBe(false);
expect(findToggle().props('value')).toBe(true);
});
it('unsubscribing from notification', async () => {
createComponent({
...mockActiveIssue,
subscribed: true,
});
mockSetActiveIssueSubscribed(false);
expect(findGlLoadingIcon().exists()).toBe(false);
findToggle().trigger('click');
await wrapper.vm.$nextTick();
expect(wrapper.vm.setActiveIssueSubscribed).toHaveBeenCalledWith({
subscribed: false,
projectPath: 'gitlab-org/test-subgroup/gitlab-test',
});
expect(findGlLoadingIcon().exists()).toBe(true);
await wrapper.vm.$nextTick();
expect(findGlLoadingIcon().exists()).toBe(false);
expect(findToggle().props('value')).toBe(false);
});
it('flashes an error message when setting the subscribed state fails', async () => {
createComponent();
jest.spyOn(wrapper.vm, 'setActiveIssueSubscribed').mockImplementation(async () => {
throw new Error();
});
findToggle().trigger('click');
await wrapper.vm.$nextTick();
expect(createFlash).toHaveBeenNthCalledWith(1, {
message: wrapper.vm.$options.i18n.updateSubscribedErrorMessage,
});
});
});
});
......@@ -176,6 +176,14 @@ export const mockIssue = {
},
};
export const mockActiveIssue = {
...mockIssue,
id: 436,
iid: '27',
subscribed: false,
emailsDisabled: false,
};
export const mockIssueWithModel = new ListIssue(mockIssue);
export const mockIssue2 = {
......
......@@ -9,6 +9,7 @@ import {
rawIssue,
mockIssues,
labels,
mockActiveIssue,
} from '../mock_data';
import actions, { gqlClient } from '~/boards/stores/actions';
import * as types from '~/boards/stores/mutation_types';
......@@ -833,6 +834,57 @@ describe('setActiveIssueDueDate', () => {
});
});
describe('setActiveIssueSubscribed', () => {
const state = { issues: { [mockActiveIssue.id]: mockActiveIssue } };
const getters = { activeIssue: mockActiveIssue };
const subscribedState = true;
const input = {
subscribedState,
projectPath: 'gitlab-org/gitlab-test',
};
it('should commit subscribed status', done => {
jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
data: {
issueSetSubscription: {
issue: {
subscribed: subscribedState,
},
errors: [],
},
},
});
const payload = {
issueId: getters.activeIssue.id,
prop: 'subscribed',
value: subscribedState,
};
testAction(
actions.setActiveIssueSubscribed,
input,
{ ...state, ...getters },
[
{
type: types.UPDATE_ISSUE_BY_ID,
payload,
},
],
[],
done,
);
});
it('throws error if fails', async () => {
jest
.spyOn(gqlClient, 'mutate')
.mockResolvedValue({ data: { issueSetSubscription: { errors: ['failed mutation'] } } });
await expect(actions.setActiveIssueSubscribed({ getters }, input)).rejects.toThrow(Error);
});
});
describe('fetchBacklog', () => {
expectNotImplemented(actions.fetchBacklog);
});
......
......@@ -124,6 +124,22 @@ describe('Boards - Getters', () => {
});
});
describe('projectPathByIssueId', () => {
it('returns project path for the active issue', () => {
const mockActiveIssue = {
referencePath: 'gitlab-org/gitlab-test#1',
};
expect(getters.projectPathForActiveIssue({}, { activeIssue: mockActiveIssue })).toEqual(
'gitlab-org/gitlab-test',
);
});
it('returns empty string as project when active issue is an empty object', () => {
const mockActiveIssue = {};
expect(getters.projectPathForActiveIssue({}, { activeIssue: mockActiveIssue })).toEqual('');
});
});
describe('getIssuesByList', () => {
const boardsState = {
issuesByListId: mockIssuesByListId,
......
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