Commit 5cb62a09 authored by Scott Stern's avatar Scott Stern Committed by Natalia Tepluhina

Convert issue sidebar confidential to apollo

Behind a FF, converting the existing full page refresh
to share a store with Sidebar and Notes
parent d9a1e681
......@@ -13,11 +13,35 @@ import sidebarTimeTrackingEventHub from '../../sidebar/event_hub';
import { isInViewport, scrollToElement, isInMRPage } from '../../lib/utils/common_utils';
import { mergeUrlParams } from '../../lib/utils/url_utility';
import mrWidgetEventHub from '../../vue_merge_request_widget/event_hub';
import updateIssueConfidentialMutation from '~/sidebar/components/confidential/queries/update_issue_confidential.mutation.graphql';
import { __, sprintf } from '~/locale';
import Api from '~/api';
let eTagPoll;
export const updateConfidentialityOnIssue = ({ commit, getters }, { confidential, fullPath }) => {
const { iid } = getters.getNoteableData;
return utils.gqClient
.mutate({
mutation: updateIssueConfidentialMutation,
variables: {
input: {
projectPath: fullPath,
iid: String(iid),
confidential,
},
},
})
.then(({ data }) => {
const {
issueSetConfidential: { issue },
} = data;
commit(types.SET_ISSUE_CONFIDENTIAL, issue.confidential);
});
};
export const expandDiscussion = ({ commit, dispatch }, data) => {
if (data.discussionId) {
dispatch('diffs/renderFileForDiscussionId', data.discussionId, { root: true });
......@@ -32,6 +56,8 @@ export const setNotesData = ({ commit }, data) => commit(types.SET_NOTES_DATA, d
export const setNoteableData = ({ commit }, data) => commit(types.SET_NOTEABLE_DATA, data);
export const setConfidentiality = ({ commit }, data) => commit(types.SET_ISSUE_CONFIDENTIAL, data);
export const setUserData = ({ commit }, data) => commit(types.SET_USER_DATA, data);
export const setLastFetchedAt = ({ commit }, data) => commit(types.SET_LAST_FETCHED_AT, data);
......
......@@ -39,6 +39,7 @@ export const CLOSE_ISSUE = 'CLOSE_ISSUE';
export const REOPEN_ISSUE = 'REOPEN_ISSUE';
export const TOGGLE_STATE_BUTTON_LOADING = 'TOGGLE_STATE_BUTTON_LOADING';
export const TOGGLE_BLOCKED_ISSUE_WARNING = 'TOGGLE_BLOCKED_ISSUE_WARNING';
export const SET_ISSUE_CONFIDENTIAL = 'SET_ISSUE_CONFIDENTIAL';
// Description version
export const REQUEST_DESCRIPTION_VERSION = 'REQUEST_DESCRIPTION_VERSION';
......
......@@ -95,6 +95,10 @@ export default {
Object.assign(state, { noteableData: data });
},
[types.SET_ISSUE_CONFIDENTIAL](state, data) {
state.noteableData.confidential = data;
},
[types.SET_USER_DATA](state, data) {
Object.assign(state, { userData: data });
},
......
import AjaxCache from '~/lib/utils/ajax_cache';
import { trimFirstCharOfLineContent } from '~/diffs/store/utils';
import { sprintf, __ } from '~/locale';
import createGqClient, { fetchPolicies } from '~/lib/graphql';
// factory function because global flag makes RegExp stateful
const createQuickActionsRegex = () => /^\/\w+.*$/gm;
......@@ -34,3 +35,10 @@ export const stripQuickActions = note => note.replace(createQuickActionsRegex(),
export const prepareDiffLines = diffLines =>
diffLines.map(line => ({ ...trimFirstCharOfLineContent(line) }));
export const gqClient = createGqClient(
{},
{
fetchPolicy: fetchPolicies.NO_CACHE,
},
);
<script>
import { mapState } from 'vuex';
import { mapState, mapActions } from 'vuex';
import { __ } from '~/locale';
import Flash from '~/flash';
import tooltip from '~/vue_shared/directives/tooltip';
......@@ -18,6 +18,10 @@ export default {
},
mixins: [recaptchaModalImplementor],
props: {
fullPath: {
required: true,
type: String,
},
isEditable: {
required: true,
type: Boolean,
......@@ -42,16 +46,24 @@ export default {
},
},
created() {
eventHub.$on('updateConfidentialAttribute', this.updateConfidentialAttribute);
eventHub.$on('closeConfidentialityForm', this.toggleForm);
},
beforeDestroy() {
eventHub.$off('updateConfidentialAttribute', this.updateConfidentialAttribute);
eventHub.$off('closeConfidentialityForm', this.toggleForm);
},
methods: {
...mapActions(['setConfidentiality']),
toggleForm() {
this.edit = !this.edit;
},
updateConfidentialAttribute(confidential) {
closeForm() {
this.edit = false;
},
updateConfidentialAttribute() {
// TODO: rm when FF is defaulted to on.
const confidential = !this.confidential;
this.service
.update('issue', { confidential })
.then(({ data }) => this.checkForSpam(data))
......@@ -97,12 +109,8 @@ export default {
>
</div>
<div class="value sidebar-item-value hide-collapsed">
<edit-form
v-if="edit"
:is-confidential="confidential"
:update-confidential-attribute="updateConfidentialAttribute"
/>
<div v-if="!confidential" class="no-value sidebar-item-value">
<edit-form v-if="edit" :is-confidential="confidential" :full-path="fullPath" />
<div v-if="!confidential" class="no-value sidebar-item-value" data-testid="not-confidential">
<icon :size="16" name="eye" aria-hidden="true" class="sidebar-item-icon inline" />
{{ __('Not confidential') }}
</div>
......
......@@ -11,9 +11,9 @@ export default {
required: true,
type: Boolean,
},
updateConfidentialAttribute: {
fullPath: {
required: true,
type: Function,
type: String,
},
},
computed: {
......@@ -37,10 +37,7 @@ export default {
<div>
<p v-if="!isConfidential" v-html="confidentialityOnWarning"></p>
<p v-else v-html="confidentialityOffWarning"></p>
<edit-form-buttons
:is-confidential="isConfidential"
:update-confidential-attribute="updateConfidentialAttribute"
/>
<edit-form-buttons :full-path="fullPath" />
</div>
</div>
</div>
......
<script>
import $ from 'jquery';
import eventHub from '../../event_hub';
import { GlLoadingIcon } from '@gitlab/ui';
import { mapActions, mapState } from 'vuex';
import { __ } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import Flash from '~/flash';
import eventHub from '../../event_hub';
export default {
components: {
GlLoadingIcon,
},
mixins: [glFeatureFlagsMixin()],
props: {
isConfidential: {
required: true,
type: Boolean,
},
updateConfidentialAttribute: {
fullPath: {
required: true,
type: Function,
type: String,
},
},
data() {
return {
isLoading: false,
};
},
computed: {
...mapState({ confidential: ({ noteableData }) => noteableData.confidential }),
toggleButtonText() {
return this.isConfidential ? __('Turn Off') : __('Turn On');
},
updateConfidentialBool() {
return !this.isConfidential;
if (this.isLoading) {
return __('Applying');
}
return this.confidential ? __('Turn Off') : __('Turn On');
},
},
methods: {
...mapActions(['updateConfidentialityOnIssue']),
closeForm() {
eventHub.$emit('closeConfidentialityForm');
$(this.$el).trigger('hidden.gl.dropdown');
},
submitForm() {
this.closeForm();
this.updateConfidentialAttribute(this.updateConfidentialBool);
this.isLoading = true;
const confidential = !this.confidential;
if (this.glFeatures.confidentialApolloSidebar) {
this.updateConfidentialityOnIssue({ confidential, fullPath: this.fullPath })
.catch(() => {
Flash(__('Something went wrong trying to change the confidentiality of this issue'));
})
.finally(() => {
this.closeForm();
this.isLoading = false;
});
} else {
eventHub.$emit('updateConfidentialAttribute');
}
},
},
};
......@@ -44,8 +69,10 @@ export default {
type="button"
class="btn btn-close"
data-testid="confidential-toggle"
:disabled="isLoading"
@click.prevent="submitForm"
>
<gl-loading-icon v-if="isLoading" inline />
{{ toggleButtonText }}
</button>
</div>
......
mutation updateIssueConfidential($input: IssueSetConfidentialInput!) {
issueSetConfidential(input: $input) {
issue {
confidential
}
}
}
......@@ -52,20 +52,30 @@ function mountAssigneesComponent(mediator) {
function mountConfidentialComponent(mediator) {
const el = document.getElementById('js-confidential-entry-point');
const { fullPath, iid } = getSidebarOptions();
if (!el) return;
const dataNode = document.getElementById('js-confidential-issue-data');
const initialData = JSON.parse(dataNode.innerHTML);
const ConfidentialComp = Vue.extend(ConfidentialIssueSidebar);
new ConfidentialComp({
// eslint-disable-next-line no-new
new Vue({
el,
store,
propsData: {
isEditable: initialData.is_editable,
service: mediator.service,
components: {
ConfidentialIssueSidebar,
},
}).$mount(el);
render: createElement =>
createElement('confidential-issue-sidebar', {
props: {
iid: String(iid),
fullPath,
isEditable: initialData.is_editable,
service: mediator.service,
},
}),
});
}
function mountLockComponent(mediator) {
......
......@@ -51,6 +51,8 @@ class Projects::IssuesController < Projects::ApplicationController
before_action only: :show do
push_frontend_feature_flag(:real_time_issue_sidebar, @project)
push_frontend_feature_flag(:confidential_notes, @project)
push_frontend_feature_flag(:confidential_apollo_sidebar, @project)
end
around_action :allow_gitaly_ref_name_caching, only: [:discussions]
......
......@@ -2680,6 +2680,9 @@ msgstr ""
msgid "Apply this approval rule to any branch or a specific protected branch."
msgstr ""
msgid "Applying"
msgstr ""
msgid "Applying a template will replace the existing issue description. Any changes you have made will be lost."
msgstr ""
......
......@@ -18,6 +18,8 @@ import {
batchSuggestionsInfoMock,
} from '../mock_data';
import axios from '~/lib/utils/axios_utils';
import * as utils from '~/notes/stores/utils';
import updateIssueConfidentialMutation from '~/sidebar/components/confidential/queries/update_issue_confidential.mutation.graphql';
const TEST_ERROR_MESSAGE = 'Test error message';
jest.mock('~/flash');
......@@ -1142,6 +1144,14 @@ describe('Actions Notes Store', () => {
});
});
describe('setConfidentiality', () => {
it('calls the correct mutation with the correct args', () => {
testAction(actions.setConfidentiality, true, { noteableData: { confidential: false } }, [
{ type: mutationTypes.SET_ISSUE_CONFIDENTIAL, payload: true },
]);
});
});
describe('updateAssignees', () => {
it('update the assignees state', done => {
testAction(
......@@ -1154,4 +1164,49 @@ describe('Actions Notes Store', () => {
);
});
});
describe('updateConfidentialityOnIssue', () => {
state = { noteableData: { confidential: false } };
const iid = '1';
const projectPath = 'full/path';
const getters = { getNoteableData: { iid } };
const actionArgs = { fullPath: projectPath, confidential: true };
const confidential = true;
beforeEach(() => {
jest
.spyOn(utils.gqClient, 'mutate')
.mockResolvedValue({ data: { issueSetConfidential: { issue: { confidential } } } });
});
it('calls gqClient mutation one time', () => {
actions.updateConfidentialityOnIssue({ commit: () => {}, state, getters }, actionArgs);
expect(utils.gqClient.mutate).toHaveBeenCalledTimes(1);
});
it('calls gqClient mutation with the correct values', () => {
actions.updateConfidentialityOnIssue({ commit: () => {}, state, getters }, actionArgs);
expect(utils.gqClient.mutate).toHaveBeenCalledWith({
mutation: updateIssueConfidentialMutation,
variables: { input: { iid, projectPath, confidential } },
});
});
describe('on success of mutation', () => {
it('calls commit with the correct values', () => {
const commitSpy = jest.fn();
return actions
.updateConfidentialityOnIssue({ commit: commitSpy, state, getters }, actionArgs)
.then(() => {
expect(commitSpy).toHaveBeenCalledWith(
mutationTypes.SET_ISSUE_CONFIDENTIAL,
confidential,
);
});
});
});
});
});
......@@ -806,6 +806,20 @@ describe('Notes Store mutations', () => {
});
});
describe('SET_ISSUE_CONFIDENTIAL', () => {
let state;
beforeEach(() => {
state = { noteableData: { confidential: false } };
});
it('sets sort order', () => {
mutations.SET_ISSUE_CONFIDENTIAL(state, true);
expect(state.noteableData.confidential).toBe(true);
});
});
describe('UPDATE_ASSIGNEES', () => {
it('should update assignees', () => {
const state = {
......
......@@ -3,6 +3,7 @@
exports[`Confidential Issue Sidebar Block renders for confidential = false and isEditable = false 1`] = `
<div
class="block issuable-sidebar-item confidentiality"
iid=""
>
<div
class="sidebar-collapsed-icon"
......@@ -35,6 +36,7 @@ exports[`Confidential Issue Sidebar Block renders for confidential = false and i
<div
class="no-value sidebar-item-value"
data-testid="not-confidential"
>
<icon-stub
aria-hidden="true"
......@@ -55,6 +57,7 @@ exports[`Confidential Issue Sidebar Block renders for confidential = false and i
exports[`Confidential Issue Sidebar Block renders for confidential = false and isEditable = true 1`] = `
<div
class="block issuable-sidebar-item confidentiality"
iid=""
>
<div
class="sidebar-collapsed-icon"
......@@ -95,6 +98,7 @@ exports[`Confidential Issue Sidebar Block renders for confidential = false and i
<div
class="no-value sidebar-item-value"
data-testid="not-confidential"
>
<icon-stub
aria-hidden="true"
......@@ -115,6 +119,7 @@ exports[`Confidential Issue Sidebar Block renders for confidential = false and i
exports[`Confidential Issue Sidebar Block renders for confidential = true and isEditable = false 1`] = `
<div
class="block issuable-sidebar-item confidentiality"
iid=""
>
<div
class="sidebar-collapsed-icon"
......@@ -167,6 +172,7 @@ exports[`Confidential Issue Sidebar Block renders for confidential = true and is
exports[`Confidential Issue Sidebar Block renders for confidential = true and isEditable = true 1`] = `
<div
class="block issuable-sidebar-item confidentiality"
iid=""
>
<div
class="sidebar-collapsed-icon"
......
import { shallowMount } from '@vue/test-utils';
import { GlLoadingIcon } from '@gitlab/ui';
import EditFormButtons from '~/sidebar/components/confidential/edit_form_buttons.vue';
import eventHub from '~/sidebar/event_hub';
import createStore from '~/notes/stores';
import waitForPromises from 'helpers/wait_for_promises';
import flash from '~/flash';
jest.mock('~/sidebar/event_hub', () => ({ $emit: jest.fn() }));
jest.mock('~/flash');
describe('Edit Form Buttons', () => {
let wrapper;
let store;
const findConfidentialToggle = () => wrapper.find('[data-testid="confidential-toggle"]');
const createComponent = props => {
const createComponent = ({
props = {},
data = {},
confidentialApolloSidebar = false,
resolved = true,
}) => {
store = createStore();
if (resolved) {
jest.spyOn(store, 'dispatch').mockResolvedValue();
} else {
jest.spyOn(store, 'dispatch').mockRejectedValue();
}
wrapper = shallowMount(EditFormButtons, {
propsData: {
updateConfidentialAttribute: () => {},
fullPath: '',
...props,
},
data() {
return {
isLoading: true,
...data,
};
},
provide: {
glFeatures: {
confidentialApolloSidebar,
},
},
store,
});
};
......@@ -19,10 +52,32 @@ describe('Edit Form Buttons', () => {
wrapper = null;
});
describe('when isLoading', () => {
beforeEach(() => {
createComponent({});
wrapper.vm.$store.state.noteableData.confidential = false;
});
it('renders "Applying" in the toggle button', () => {
expect(findConfidentialToggle().text()).toBe('Applying');
});
it('disables the toggle button', () => {
expect(findConfidentialToggle().attributes('disabled')).toBe('disabled');
});
it('finds the GlLoadingIcon', () => {
expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
});
});
describe('when not confidential', () => {
it('renders Turn On in the ', () => {
it('renders Turn On in the toggle button', () => {
createComponent({
isConfidential: false,
data: {
isLoading: false,
},
});
expect(findConfidentialToggle().text()).toBe('Turn On');
......@@ -30,12 +85,75 @@ describe('Edit Form Buttons', () => {
});
describe('when confidential', () => {
it('renders on or off text based on confidentiality', () => {
beforeEach(() => {
createComponent({
isConfidential: true,
data: {
isLoading: false,
},
});
wrapper.vm.$store.state.noteableData.confidential = true;
});
it('renders on or off text based on confidentiality', () => {
expect(findConfidentialToggle().text()).toBe('Turn Off');
});
describe('when clicking on the confidential toggle', () => {
it('emits updateConfidentialAttribute', () => {
findConfidentialToggle().trigger('click');
expect(eventHub.$emit).toHaveBeenCalledWith('updateConfidentialAttribute');
});
});
});
describe('when confidentialApolloSidebar is turned on', () => {
const isConfidential = true;
describe('when succeeds', () => {
beforeEach(() => {
createComponent({ data: { isLoading: false }, confidentialApolloSidebar: true });
wrapper.vm.$store.state.noteableData.confidential = isConfidential;
findConfidentialToggle().trigger('click');
});
it('dispatches the correct action', () => {
expect(store.dispatch).toHaveBeenCalledWith('updateConfidentialityOnIssue', {
confidential: !isConfidential,
fullPath: '',
});
});
it('resets loading', () => {
return waitForPromises().then(() => {
expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
});
});
it('emits close form', () => {
return waitForPromises().then(() => {
expect(eventHub.$emit).toHaveBeenCalledWith('closeConfidentialityForm');
});
});
});
describe('when fails', () => {
beforeEach(() => {
createComponent({
data: { isLoading: false },
confidentialApolloSidebar: true,
resolved: false,
});
wrapper.vm.$store.state.noteableData.confidential = isConfidential;
findConfidentialToggle().trigger('click');
});
it('calls flash with the correct message', () => {
expect(flash).toHaveBeenCalledWith(
'Something went wrong trying to change the confidentiality of this issue',
);
});
});
});
});
......@@ -10,6 +10,8 @@ describe('Edit Form Dropdown', () => {
wrapper = shallowMount(EditForm, {
propsData: {
...props,
isLoading: false,
fullPath: '',
},
});
};
......
......@@ -7,6 +7,7 @@ import createFlash from '~/flash';
import RecaptchaModal from '~/vue_shared/components/recaptcha_modal.vue';
import createStore from '~/notes/stores';
import { useMockLocationHelper } from 'helpers/mock_window_location_helper';
import eventHub from '~/sidebar/event_hub';
jest.mock('~/flash');
jest.mock('~/sidebar/services/sidebar_service');
......@@ -15,6 +16,9 @@ describe('Confidential Issue Sidebar Block', () => {
useMockLocationHelper();
let wrapper;
const mutate = jest
.fn()
.mockResolvedValue({ data: { issueSetConfidential: { issue: { confidential: true } } } });
const findRecaptchaModal = () => wrapper.find(RecaptchaModal);
......@@ -25,24 +29,32 @@ describe('Confidential Issue Sidebar Block', () => {
wrapper.vm
.$nextTick()
.then(() => {
const editForm = wrapper.find(EditForm);
const { updateConfidentialAttribute } = editForm.props();
updateConfidentialAttribute();
eventHub.$emit('updateConfidentialAttribute');
})
// wait for reCAPTCHA modal to render
.then(() => wrapper.vm.$nextTick())
);
};
const createComponent = propsData => {
const createComponent = ({ propsData, data = {} }) => {
const store = createStore();
const service = new SidebarService();
wrapper = shallowMount(ConfidentialIssueSidebar, {
store,
data() {
return data;
},
propsData: {
service,
iid: '',
fullPath: '',
...propsData,
},
mocks: {
$apollo: {
mutate,
},
},
});
};
......@@ -60,7 +72,9 @@ describe('Confidential Issue Sidebar Block', () => {
'renders for confidential = $confidential and isEditable = $isEditable',
({ confidential, isEditable }) => {
createComponent({
isEditable,
propsData: {
isEditable,
},
});
wrapper.vm.$store.state.noteableData.confidential = confidential;
......@@ -73,7 +87,9 @@ describe('Confidential Issue Sidebar Block', () => {
describe('if editable', () => {
beforeEach(() => {
createComponent({
isEditable: true,
propsData: {
isEditable: true,
},
});
wrapper.vm.$store.state.noteableData.confidential = true;
});
......
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