Commit a93dfc1b authored by GitLab Bot's avatar GitLab Bot

Add latest changes from gitlab-org/gitlab@master

parent 188a57f9
import Vue from 'vue';
import VueResource from 'vue-resource';
import axios from '~/lib/utils/axios_utils';
import * as constants from '../constants';
Vue.use(VueResource);
export default {
fetchDiscussions(endpoint, filter, persistFilter = true) {
const config =
filter !== undefined
? { params: { notes_filter: filter, persist_filter: persistFilter } }
: null;
return Vue.http.get(endpoint, config);
return axios.get(endpoint, config);
},
replyToDiscussion(endpoint, data) {
return Vue.http.post(endpoint, data, { emulateJSON: true });
return axios.post(endpoint, data);
},
updateNote(endpoint, data) {
return Vue.http.put(endpoint, data, { emulateJSON: true });
return axios.put(endpoint, data);
},
createNewNote(endpoint, data) {
return Vue.http.post(endpoint, data, { emulateJSON: true });
return axios.post(endpoint, data);
},
toggleResolveNote(endpoint, isResolved) {
const { RESOLVE_NOTE_METHOD_NAME, UNRESOLVE_NOTE_METHOD_NAME } = constants;
const method = isResolved ? UNRESOLVE_NOTE_METHOD_NAME : RESOLVE_NOTE_METHOD_NAME;
return Vue.http[method](endpoint);
return axios[method](endpoint);
},
poll(data = {}) {
const endpoint = data.notesData.notesPath;
......@@ -36,9 +33,9 @@ export default {
},
};
return Vue.http.get(endpoint, options);
return axios.get(endpoint, options);
},
toggleIssueState(endpoint, data) {
return Vue.http.put(endpoint, data);
return axios.put(endpoint, data);
},
};
......@@ -47,13 +47,10 @@ export const setNotesFetchedState = ({ commit }, state) =>
export const toggleDiscussion = ({ commit }, data) => commit(types.TOGGLE_DISCUSSION, data);
export const fetchDiscussions = ({ commit, dispatch }, { path, filter, persistFilter }) =>
service
.fetchDiscussions(path, filter, persistFilter)
.then(res => res.json())
.then(discussions => {
commit(types.SET_INITIAL_DISCUSSIONS, discussions);
dispatch('updateResolvableDiscussionsCounts');
});
service.fetchDiscussions(path, filter, persistFilter).then(({ data }) => {
commit(types.SET_INITIAL_DISCUSSIONS, data);
dispatch('updateResolvableDiscussionsCounts');
});
export const updateDiscussion = ({ commit, state }, discussion) => {
commit(types.UPDATE_DISCUSSION, discussion);
......@@ -80,13 +77,10 @@ export const deleteNote = ({ dispatch }, note) =>
});
export const updateNote = ({ commit, dispatch }, { endpoint, note }) =>
service
.updateNote(endpoint, note)
.then(res => res.json())
.then(res => {
commit(types.UPDATE_NOTE, res);
dispatch('startTaskList');
});
service.updateNote(endpoint, note).then(({ data }) => {
commit(types.UPDATE_NOTE, data);
dispatch('startTaskList');
});
export const updateOrCreateNotes = ({ commit, state, getters, dispatch }, notes) => {
const { notesById } = getters;
......@@ -110,40 +104,37 @@ export const updateOrCreateNotes = ({ commit, state, getters, dispatch }, notes)
});
};
export const replyToDiscussion = ({ commit, state, getters, dispatch }, { endpoint, data }) =>
service
.replyToDiscussion(endpoint, data)
.then(res => res.json())
.then(res => {
if (res.discussion) {
commit(types.UPDATE_DISCUSSION, res.discussion);
export const replyToDiscussion = (
{ commit, state, getters, dispatch },
{ endpoint, data: reply },
) =>
service.replyToDiscussion(endpoint, reply).then(({ data }) => {
if (data.discussion) {
commit(types.UPDATE_DISCUSSION, data.discussion);
updateOrCreateNotes({ commit, state, getters, dispatch }, res.discussion.notes);
updateOrCreateNotes({ commit, state, getters, dispatch }, data.discussion.notes);
dispatch('updateMergeRequestWidget');
dispatch('startTaskList');
dispatch('updateResolvableDiscussionsCounts');
} else {
commit(types.ADD_NEW_REPLY_TO_DISCUSSION, res);
}
dispatch('updateMergeRequestWidget');
dispatch('startTaskList');
dispatch('updateResolvableDiscussionsCounts');
} else {
commit(types.ADD_NEW_REPLY_TO_DISCUSSION, data);
}
return res;
});
return data;
});
export const createNewNote = ({ commit, dispatch }, { endpoint, data }) =>
service
.createNewNote(endpoint, data)
.then(res => res.json())
.then(res => {
if (!res.errors) {
commit(types.ADD_NEW_NOTE, res);
dispatch('updateMergeRequestWidget');
dispatch('startTaskList');
dispatch('updateResolvableDiscussionsCounts');
}
return res;
});
export const createNewNote = ({ commit, dispatch }, { endpoint, data: reply }) =>
service.createNewNote(endpoint, reply).then(({ data }) => {
if (!data.errors) {
commit(types.ADD_NEW_NOTE, data);
dispatch('updateMergeRequestWidget');
dispatch('startTaskList');
dispatch('updateResolvableDiscussionsCounts');
}
return data;
});
export const removePlaceholderNotes = ({ commit }) => commit(types.REMOVE_PLACEHOLDER_NOTES);
......@@ -165,41 +156,32 @@ export const resolveDiscussion = ({ state, dispatch, getters }, { discussionId }
};
export const toggleResolveNote = ({ commit, dispatch }, { endpoint, isResolved, discussion }) =>
service
.toggleResolveNote(endpoint, isResolved)
.then(res => res.json())
.then(res => {
const mutationType = discussion ? types.UPDATE_DISCUSSION : types.UPDATE_NOTE;
service.toggleResolveNote(endpoint, isResolved).then(({ data }) => {
const mutationType = discussion ? types.UPDATE_DISCUSSION : types.UPDATE_NOTE;
commit(mutationType, res);
commit(mutationType, data);
dispatch('updateResolvableDiscussionsCounts');
dispatch('updateResolvableDiscussionsCounts');
dispatch('updateMergeRequestWidget');
});
dispatch('updateMergeRequestWidget');
});
export const closeIssue = ({ commit, dispatch, state }) => {
dispatch('toggleStateButtonLoading', true);
return service
.toggleIssueState(state.notesData.closePath)
.then(res => res.json())
.then(data => {
commit(types.CLOSE_ISSUE);
dispatch('emitStateChangedEvent', data);
dispatch('toggleStateButtonLoading', false);
});
return service.toggleIssueState(state.notesData.closePath).then(({ data }) => {
commit(types.CLOSE_ISSUE);
dispatch('emitStateChangedEvent', data);
dispatch('toggleStateButtonLoading', false);
});
};
export const reopenIssue = ({ commit, dispatch, state }) => {
dispatch('toggleStateButtonLoading', true);
return service
.toggleIssueState(state.notesData.reopenPath)
.then(res => res.json())
.then(data => {
commit(types.REOPEN_ISSUE);
dispatch('emitStateChangedEvent', data);
dispatch('toggleStateButtonLoading', false);
});
return service.toggleIssueState(state.notesData.reopenPath).then(({ data }) => {
commit(types.REOPEN_ISSUE);
dispatch('emitStateChangedEvent', data);
dispatch('toggleStateButtonLoading', false);
});
};
export const toggleStateButtonLoading = ({ commit }, value) =>
......@@ -340,8 +322,7 @@ export const poll = ({ commit, state, getters, dispatch }) => {
resource: service,
method: 'poll',
data: state,
successCallback: resp =>
resp.json().then(data => pollSuccessCallBack(data, commit, state, getters, dispatch)),
successCallback: ({ data }) => pollSuccessCallBack(data, commit, state, getters, dispatch),
errorCallback: () => Flash(__('Something went wrong while fetching latest comments.')),
});
......@@ -376,8 +357,7 @@ export const fetchData = ({ commit, state, getters }) => {
service
.poll(requestData)
.then(resp => resp.json)
.then(data => pollSuccessCallBack(data, commit, state, getters))
.then(({ data }) => pollSuccessCallBack(data, commit, state, getters))
.catch(() => Flash(__('Something went wrong while fetching latest comments.')));
};
......
<script>
import { GlLink, GlTooltipDirective } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import { s__ } from '~/locale';
export default {
name: 'MilestoneList',
components: {
GlLink,
Icon,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
milestones: {
type: Array,
required: true,
},
},
computed: {
labelText() {
return this.milestones.length === 1 ? s__('Milestone') : s__('Milestones');
},
},
};
</script>
<template>
<div>
<icon name="flag" class="align-middle" /> <span class="js-label-text">{{ labelText }}</span>
<template v-for="(milestone, index) in milestones">
<gl-link
:key="milestone.id"
v-gl-tooltip
:title="milestone.description"
:href="milestone.web_url"
>
{{ milestone.title }}
</gl-link>
<template v-if="index !== milestones.length - 1">
&bull;
</template>
</template>
</div>
</template>
......@@ -5,6 +5,7 @@ import { GlTooltipDirective, GlLink, GlBadge } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import MilestoneList from './milestone_list.vue';
import { __, sprintf } from '../../locale';
export default {
......@@ -14,6 +15,7 @@ export default {
GlBadge,
Icon,
UserAvatarLink,
MilestoneList,
},
directives: {
GlTooltip: GlTooltipDirective,
......@@ -49,6 +51,20 @@ export default {
hasAuthor() {
return !_.isEmpty(this.author);
},
milestones() {
// At the moment, a release can only be associated to
// one milestone. This will be expanded to be many-to-many
// in the near future, so we pass the milestone as an
// array here in anticipation of this change.
return [this.release.milestone];
},
shouldRenderMilestones() {
// Similar to the `milestones` computed above,
// this check will need to be updated once
// the API begins sending an array of milestones
// instead of just a single object.
return Boolean(this.release.milestone);
},
},
};
</script>
......@@ -73,6 +89,12 @@ export default {
<span v-gl-tooltip.bottom :title="__('Tag')">{{ release.tag_name }}</span>
</div>
<milestone-list
v-if="shouldRenderMilestones"
class="append-right-4 js-milestone-list"
:milestones="milestones"
/>
<div class="append-right-4">
&bull;
<span v-gl-tooltip.bottom :title="tooltipTitle(release.released_at)">
......
# frozen_string_literal: true
module Issues
class ZoomLinkService < Issues::BaseService
def initialize(issue, user)
super(issue.project, user)
@issue = issue
end
def add_link(link)
if can_add_link? && (link = parse_link(link))
success(_('Zoom meeting added'), append_to_description(link))
else
error(_('Failed to add a Zoom meeting'))
end
end
def can_add_link?
available? && !link_in_issue_description?
end
def remove_link
if can_remove_link?
success(_('Zoom meeting removed'), remove_from_description)
else
error(_('Failed to remove a Zoom meeting'))
end
end
def can_remove_link?
available? && link_in_issue_description?
end
def parse_link(link)
Gitlab::ZoomLinkExtractor.new(link).links.last
end
private
attr_reader :issue
def issue_description
issue.description || ''
end
def success(message, description)
ServiceResponse
.success(message: message, payload: { description: description })
end
def error(message)
ServiceResponse.error(message: message)
end
def append_to_description(link)
"#{issue_description}\n\n#{link}"
end
def remove_from_description
link = parse_link(issue_description)
return issue_description unless link
issue_description.delete_suffix(link).rstrip
end
def link_in_issue_description?
link = extract_link_from_issue_description
return unless link
Gitlab::ZoomLinkExtractor.new(link).match?
end
def extract_link_from_issue_description
issue_description[/(\S+)\z/, 1]
end
def available?
feature_enabled? && can?
end
def feature_enabled?
Feature.enabled?(:issue_zoom_integration, project)
end
def can?
current_user.can?(:update_issue, project)
end
end
end
---
title: Remove vue-resource from notes service
merge_request: 32934
author: Lee Tickett
type: other
......@@ -64,6 +64,8 @@ The following quick actions are applicable to descriptions, discussions and thre
| `/create_merge_request <branch name>` | ✓ | | | Create a new merge request starting from the current issue |
| `/relate #issue1 #issue2` | ✓ | | | Mark issues as related **(STARTER)** |
| `/move <path/to/project>` | ✓ | | | Move this issue to another project |
| `/zoom <Zoom URL>` | ✓ | | | Add Zoom meeting to this issue. ([Introduced in GitLab 12.3](https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/16609) enabled by feature flag `issue_zoom_integration`) |
| `/remove_zoom` | ✓ | | | Remove Zoom meeting from this issue. ([Introduced in GitLab 12.3](https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/16609) enabled by feature flag `issue_zoom_integration`) |
| `/target_branch <local branch name>` | | ✓ | | Set target branch |
| `/wip` | | ✓ | | Toggle the Work In Progress status |
| `/approve` | | ✓ | | Approve the merge request |
......
......@@ -31,6 +31,9 @@ module.exports = {
moduleNameMapper: {
'^~(/.*)$': '<rootDir>/app/assets/javascripts$1',
'^ee(/.*)$': '<rootDir>/ee/app/assets/javascripts$1',
'^ee_component(/.*)$': IS_EE
? '<rootDir>/ee/app/assets/javascripts$1'
: '<rootDir>/app/assets/javascripts/vue_shared/components/empty_component.js',
'^ee_else_ce(/.*)$': IS_EE
? '<rootDir>/ee/app/assets/javascripts$1'
: '<rootDir>/app/assets/javascripts$1',
......
......@@ -167,6 +167,49 @@ module Gitlab
issue_iid: quick_action_target.iid
}
end
desc _('Add Zoom meeting')
explanation _('Adds a Zoom meeting')
params '<Zoom URL>'
types Issue
condition do
zoom_link_service.can_add_link?
end
parse_params do |link|
zoom_link_service.parse_link(link)
end
command :zoom do |link|
result = zoom_link_service.add_link(link)
if result.success?
@updates[:description] = result.payload[:description]
end
@execution_message[:zoom] = result.message
end
desc _('Remove Zoom meeting')
explanation _('Remove Zoom meeting')
execution_message _('Zoom meeting removed')
types Issue
condition do
zoom_link_service.can_remove_link?
end
command :remove_zoom do
result = zoom_link_service.remove_link
if result.success?
@updates[:description] = result.payload[:description]
end
@execution_message[:remove_zoom] = result.message
end
private
def zoom_link_service
Issues::ZoomLinkService.new(quick_action_target, current_user)
end
end
end
end
......
......@@ -836,6 +836,9 @@ msgstr ""
msgid "Add README"
msgstr ""
msgid "Add Zoom meeting"
msgstr ""
msgid "Add a %{type} token"
msgstr ""
......@@ -1007,6 +1010,9 @@ msgstr ""
msgid "Adds a To Do."
msgstr ""
msgid "Adds a Zoom meeting"
msgstr ""
msgid "Adds an issue to an epic."
msgstr ""
......@@ -6268,6 +6274,9 @@ msgstr ""
msgid "Failed create wiki"
msgstr ""
msgid "Failed to add a Zoom meeting"
msgstr ""
msgid "Failed to apply commands."
msgstr ""
......@@ -6340,6 +6349,9 @@ msgstr ""
msgid "Failed to protect the environment"
msgstr ""
msgid "Failed to remove a Zoom meeting"
msgstr ""
msgid "Failed to remove issue from board, please try again."
msgstr ""
......@@ -12672,6 +12684,9 @@ msgstr ""
msgid "Remove Runner"
msgstr ""
msgid "Remove Zoom meeting"
msgstr ""
msgid "Remove all approvals in a merge request when new commits are pushed to its source branch"
msgstr ""
......@@ -18118,6 +18133,12 @@ msgstr ""
msgid "Your request for access has been queued for review."
msgstr ""
msgid "Zoom meeting added"
msgstr ""
msgid "Zoom meeting removed"
msgstr ""
msgid "a deleted user"
msgstr ""
......
......@@ -39,6 +39,7 @@ module QA
end
Page::Project::Issue::Show.perform do |show|
show.select_all_activities_filter
expect(show).to have_element(:reopen_issue_button)
expect(show).to have_content("closed via commit #{commit_sha}")
end
......
# frozen_string_literal: true
module QA
context 'Plan' do
context 'Plan', :smoke do
describe 'mention' do
let(:user) do
Resource::User.fabricate_via_api! do |user|
......
......@@ -42,5 +42,6 @@ describe 'Issues > User uses quick actions', :js do
it_behaves_like 'create_merge_request quick action'
it_behaves_like 'move quick action'
it_behaves_like 'zoom quick actions'
end
end
import $ from 'helpers/jquery';
import AxiosMockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import Vue from 'vue';
import { mount, createLocalVue } from '@vue/test-utils';
import NotesApp from '~/notes/components/notes_app.vue';
......@@ -9,19 +11,10 @@ import { setTestTimeout } from 'helpers/timeout';
// TODO: use generated fixture (https://gitlab.com/gitlab-org/gitlab-ce/issues/62491)
import * as mockData from '../../../javascripts/notes/mock_data';
const originalInterceptors = [...Vue.http.interceptors];
const emptyResponseInterceptor = (request, next) => {
next(
request.respondWith(JSON.stringify([]), {
status: 200,
}),
);
};
setTestTimeout(1000);
describe('note_app', () => {
let axiosMock;
let mountComponent;
let wrapper;
let store;
......@@ -45,6 +38,8 @@ describe('note_app', () => {
beforeEach(() => {
$('body').attr('data-page', 'projects:merge_requests:show');
axiosMock = new AxiosMockAdapter(axios);
store = createStore();
mountComponent = data => {
const propsData = data || {
......@@ -74,12 +69,12 @@ describe('note_app', () => {
afterEach(() => {
wrapper.destroy();
Vue.http.interceptors = [...originalInterceptors];
axiosMock.restore();
});
describe('set data', () => {
beforeEach(() => {
Vue.http.interceptors.push(emptyResponseInterceptor);
axiosMock.onAny().reply(200, []);
wrapper = mountComponent();
return waitForDiscussionsRequest();
});
......@@ -105,7 +100,7 @@ describe('note_app', () => {
beforeEach(() => {
setFixtures('<div class="js-discussions-count"></div>');
Vue.http.interceptors.push(mockData.individualNoteInterceptor);
axiosMock.onAny().reply(mockData.getIndividualNoteResponse);
wrapper = mountComponent();
return waitForDiscussionsRequest();
});
......@@ -146,7 +141,7 @@ describe('note_app', () => {
beforeEach(() => {
setFixtures('<div class="js-discussions-count"></div>');
Vue.http.interceptors.push(mockData.individualNoteInterceptor);
axiosMock.onAny().reply(mockData.getIndividualNoteResponse);
store.state.commentsDisabled = true;
wrapper = mountComponent();
return waitForDiscussionsRequest();
......@@ -163,7 +158,7 @@ describe('note_app', () => {
describe('while fetching data', () => {
beforeEach(() => {
Vue.http.interceptors.push(emptyResponseInterceptor);
axiosMock.onAny().reply(200, []);
wrapper = mountComponent();
});
......@@ -184,7 +179,7 @@ describe('note_app', () => {
describe('update note', () => {
describe('individual note', () => {
beforeEach(() => {
Vue.http.interceptors.push(mockData.individualNoteInterceptor);
axiosMock.onAny().reply(mockData.getIndividualNoteResponse);
jest.spyOn(service, 'updateNote');
wrapper = mountComponent();
return waitForDiscussionsRequest().then(() => {
......@@ -206,7 +201,7 @@ describe('note_app', () => {
describe('discussion note', () => {
beforeEach(() => {
Vue.http.interceptors.push(mockData.discussionNoteInterceptor);
axiosMock.onAny().reply(mockData.getDiscussionNoteResponse);
jest.spyOn(service, 'updateNote');
wrapper = mountComponent();
return waitForDiscussionsRequest().then(() => {
......@@ -229,7 +224,7 @@ describe('note_app', () => {
describe('new note form', () => {
beforeEach(() => {
Vue.http.interceptors.push(mockData.individualNoteInterceptor);
axiosMock.onAny().reply(mockData.getIndividualNoteResponse);
wrapper = mountComponent();
return waitForDiscussionsRequest();
});
......@@ -259,7 +254,7 @@ describe('note_app', () => {
describe('edit form', () => {
beforeEach(() => {
Vue.http.interceptors.push(mockData.individualNoteInterceptor);
axiosMock.onAny().reply(mockData.getIndividualNoteResponse);
wrapper = mountComponent();
return waitForDiscussionsRequest();
});
......@@ -287,7 +282,7 @@ describe('note_app', () => {
describe('emoji awards', () => {
beforeEach(() => {
Vue.http.interceptors.push(emptyResponseInterceptor);
axiosMock.onAny().reply(200, []);
wrapper = mountComponent();
return waitForDiscussionsRequest();
});
......
import { shallowMount } from '@vue/test-utils';
import { GlLink } from '@gitlab/ui';
import MilestoneList from '~/releases/components/milestone_list.vue';
import Icon from '~/vue_shared/components/icon.vue';
import _ from 'underscore';
import { milestones } from '../mock_data';
describe('Milestone list', () => {
let wrapper;
const factory = milestonesProp => {
wrapper = shallowMount(MilestoneList, {
propsData: {
milestones: milestonesProp,
},
sync: false,
});
};
afterEach(() => {
wrapper.destroy();
});
it('renders the milestone icon', () => {
factory(milestones);
expect(wrapper.find(Icon).exists()).toBe(true);
});
it('renders the label as "Milestone" if only a single milestone is passed in', () => {
factory(milestones.slice(0, 1));
expect(wrapper.find('.js-label-text').text()).toEqual('Milestone');
});
it('renders the label as "Milestones" if more than one milestone is passed in', () => {
factory(milestones);
expect(wrapper.find('.js-label-text').text()).toEqual('Milestones');
});
it('renders a link to the milestone with a tooltip', () => {
const milestone = _.first(milestones);
factory([milestone]);
const milestoneLink = wrapper.find(GlLink);
expect(milestoneLink.exists()).toBe(true);
expect(milestoneLink.text()).toBe(milestone.title);
expect(milestoneLink.attributes('href')).toBe(milestone.web_url);
expect(milestoneLink.attributes('data-original-title')).toBe(milestone.description);
});
});
import { mount } from '@vue/test-utils';
import ReleaseBlock from '~/releases/components/release_block.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import { first } from 'underscore';
import { release } from '../mock_data';
describe('Release block', () => {
let wrapper;
const factory = releaseProp => {
wrapper = mount(ReleaseBlock, {
propsData: {
release: releaseProp,
},
sync: false,
});
};
const milestoneListExists = () => wrapper.find('.js-milestone-list').exists();
afterEach(() => {
wrapper.destroy();
});
describe('with default props', () => {
beforeEach(() => {
factory(release);
});
it("renders the block with an id equal to the release's tag name", () => {
expect(wrapper.attributes().id).toBe('v0.3');
});
it('renders release name', () => {
expect(wrapper.text()).toContain(release.name);
});
it('renders commit sha', () => {
expect(wrapper.text()).toContain(release.commit.short_id);
});
it('renders tag name', () => {
expect(wrapper.text()).toContain(release.tag_name);
});
it('renders release date', () => {
expect(wrapper.text()).toContain(timeagoMixin.methods.timeFormated(release.released_at));
});
it('renders number of assets provided', () => {
expect(wrapper.find('.js-assets-count').text()).toContain(release.assets.count);
});
it('renders dropdown with the sources', () => {
expect(wrapper.findAll('.js-sources-dropdown li').length).toEqual(
release.assets.sources.length,
);
expect(wrapper.find('.js-sources-dropdown li a').attributes().href).toEqual(
first(release.assets.sources).url,
);
expect(wrapper.find('.js-sources-dropdown li a').text()).toContain(
first(release.assets.sources).format,
);
});
it('renders list with the links provided', () => {
expect(wrapper.findAll('.js-assets-list li').length).toEqual(release.assets.links.length);
expect(wrapper.find('.js-assets-list li a').attributes().href).toEqual(
first(release.assets.links).url,
);
expect(wrapper.find('.js-assets-list li a').text()).toContain(
first(release.assets.links).name,
);
});
it('renders author avatar', () => {
expect(wrapper.find('.user-avatar-link').exists()).toBe(true);
});
describe('external label', () => {
it('renders external label when link is external', () => {
expect(wrapper.find('.js-assets-list li a').text()).toContain('external source');
});
it('does not render external label when link is not external', () => {
expect(wrapper.find('.js-assets-list li:nth-child(2) a').text()).not.toContain(
'external source',
);
});
});
it('renders the milestone list if at least one milestone is associated to the release', () => {
factory(release);
expect(milestoneListExists()).toBe(true);
});
});
it('does not render the milestone list if no milestones are associated to the release', () => {
const releaseClone = JSON.parse(JSON.stringify(release));
delete releaseClone.milestone;
factory(releaseClone);
expect(milestoneListExists()).toBe(false);
});
it('renders upcoming release badge', () => {
const releaseClone = JSON.parse(JSON.stringify(release));
releaseClone.upcoming_release = true;
factory(releaseClone);
expect(wrapper.text()).toContain('Upcoming Release');
});
});
export const milestones = [
{
id: 50,
iid: 2,
project_id: 18,
title: '13.6',
description: 'The 13.6 milestone!',
state: 'active',
created_at: '2019-08-27T17:22:38.280Z',
updated_at: '2019-08-27T17:22:38.280Z',
due_date: '2019-09-19',
start_date: '2019-08-31',
web_url: 'http://0.0.0.0:3001/root/release-test/-/milestones/2',
},
{
id: 49,
iid: 1,
project_id: 18,
title: '13.5',
description: 'The 13.5 milestone!',
state: 'active',
created_at: '2019-08-26T17:55:48.643Z',
updated_at: '2019-08-26T17:55:48.643Z',
due_date: '2019-10-11',
start_date: '2019-08-19',
web_url: 'http://0.0.0.0:3001/root/release-test/-/milestones/1',
},
];
export const release = {
name: 'New release',
tag_name: 'v0.3',
description: 'A super nice release!',
description_html: '<p data-sourcepos="1:1-1:21" dir="auto">A super nice release!</p>',
created_at: '2019-08-26T17:54:04.952Z',
released_at: '2019-08-26T17:54:04.807Z',
author: {
id: 1,
name: 'Administrator',
username: 'root',
state: 'active',
avatar_url: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
web_url: 'http://0.0.0.0:3001/root',
},
commit: {
id: 'c22b0728d1b465f82898c884d32b01aa642f96c1',
short_id: 'c22b0728',
created_at: '2019-08-26T17:47:07.000Z',
parent_ids: [],
title: 'Initial commit',
message: 'Initial commit',
author_name: 'Administrator',
author_email: 'admin@example.com',
authored_date: '2019-08-26T17:47:07.000Z',
committer_name: 'Administrator',
committer_email: 'admin@example.com',
committed_date: '2019-08-26T17:47:07.000Z',
},
upcoming_release: false,
milestone: milestones[0],
assets: {
count: 5,
sources: [
{
format: 'zip',
url: 'http://0.0.0.0:3001/root/release-test/-/archive/v0.3/release-test-v0.3.zip',
},
{
format: 'tar.gz',
url: 'http://0.0.0.0:3001/root/release-test/-/archive/v0.3/release-test-v0.3.tar.gz',
},
{
format: 'tar.bz2',
url: 'http://0.0.0.0:3001/root/release-test/-/archive/v0.3/release-test-v0.3.tar.bz2',
},
{
format: 'tar',
url: 'http://0.0.0.0:3001/root/release-test/-/archive/v0.3/release-test-v0.3.tar',
},
],
links: [
{
id: 1,
name: 'my link',
url: 'https://google.com',
external: true,
},
{
id: 2,
name: 'my second link',
url:
'https://gitlab.com/gitlab-org/gitlab-ce/-/jobs/artifacts/v11.6.0-rc4/download?job=rspec-mysql+41%2F50',
external: false,
},
],
},
};
......@@ -647,24 +647,12 @@ export const DISCUSSION_NOTE_RESPONSE_MAP = {
},
};
export function individualNoteInterceptor(request, next) {
const body = INDIVIDUAL_NOTE_RESPONSE_MAP[request.method.toUpperCase()][request.url];
next(
request.respondWith(JSON.stringify(body), {
status: 200,
}),
);
export function getIndividualNoteResponse(config) {
return [200, INDIVIDUAL_NOTE_RESPONSE_MAP[config.method.toUpperCase()][config.url]];
}
export function discussionNoteInterceptor(request, next) {
const body = DISCUSSION_NOTE_RESPONSE_MAP[request.method.toUpperCase()][request.url];
next(
request.respondWith(JSON.stringify(body), {
status: 200,
}),
);
export function getDiscussionNoteResponse(config) {
return [200, DISCUSSION_NOTE_RESPONSE_MAP[config.method.toUpperCase()][config.url]];
}
export const notesWithDescriptionChanges = [
......
import Vue from 'vue';
import $ from 'jquery';
import _ from 'underscore';
import Api from '~/api';
import { TEST_HOST } from 'spec/test_constants';
import { headersInterceptor } from 'spec/helpers/vue_resource_helper';
import actionsModule, * as actions from '~/notes/stores/actions';
import * as mutationTypes from '~/notes/stores/mutation_types';
import * as notesConstants from '~/notes/constants';
......@@ -29,6 +26,7 @@ describe('Actions Notes Store', () => {
let state;
let store;
let flashSpy;
let axiosMock;
beforeEach(() => {
store = createStore();
......@@ -36,10 +34,12 @@ describe('Actions Notes Store', () => {
dispatch = jasmine.createSpy('dispatch');
state = {};
flashSpy = spyOnDependency(actionsModule, 'Flash');
axiosMock = new AxiosMockAdapter(axios);
});
afterEach(() => {
resetStore(store);
axiosMock.restore();
});
describe('setNotesData', () => {
......@@ -160,20 +160,8 @@ describe('Actions Notes Store', () => {
});
describe('async methods', () => {
const interceptor = (request, next) => {
next(
request.respondWith(JSON.stringify({}), {
status: 200,
}),
);
};
beforeEach(() => {
Vue.http.interceptors.push(interceptor);
});
afterEach(() => {
Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor);
axiosMock.onAny().reply(200, {});
});
describe('closeIssue', () => {
......@@ -259,7 +247,7 @@ describe('Actions Notes Store', () => {
beforeEach(done => {
jasmine.clock().install();
spyOn(Vue.http, 'get').and.callThrough();
spyOn(axios, 'get').and.callThrough();
store
.dispatch('setNotesData', notesDataMock)
......@@ -272,31 +260,15 @@ describe('Actions Notes Store', () => {
});
it('calls service with last fetched state', done => {
const interceptor = (request, next) => {
next(
request.respondWith(
JSON.stringify({
notes: [],
last_fetched_at: '123456',
}),
{
status: 200,
headers: {
'poll-interval': '1000',
},
},
),
);
};
Vue.http.interceptors.push(interceptor);
Vue.http.interceptors.push(headersInterceptor);
axiosMock
.onAny()
.reply(200, { notes: [], last_fetched_at: '123456' }, { 'poll-interval': '1000' });
store
.dispatch('poll')
.then(() => new Promise(resolve => requestAnimationFrame(resolve)))
.then(() => {
expect(Vue.http.get).toHaveBeenCalled();
expect(axios.get).toHaveBeenCalled();
expect(store.state.lastFetchedAt).toBe('123456');
jasmine.clock().tick(1500);
......@@ -308,16 +280,12 @@ describe('Actions Notes Store', () => {
}),
)
.then(() => {
expect(Vue.http.get.calls.count()).toBe(2);
expect(Vue.http.get.calls.mostRecent().args[1].headers).toEqual({
expect(axios.get.calls.count()).toBe(2);
expect(axios.get.calls.mostRecent().args[1].headers).toEqual({
'X-Last-Fetched-At': '123456',
});
})
.then(() => store.dispatch('stopPolling'))
.then(() => {
Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor);
Vue.http.interceptors = _.without(Vue.http.interceptors, headersInterceptor);
})
.then(done)
.catch(done.fail);
});
......@@ -338,10 +306,8 @@ describe('Actions Notes Store', () => {
describe('removeNote', () => {
const endpoint = `${TEST_HOST}/note`;
let axiosMock;
beforeEach(() => {
axiosMock = new AxiosMockAdapter(axios);
axiosMock.onDelete(endpoint).replyOnce(200, {});
$('body').attr('data-page', '');
......@@ -411,10 +377,8 @@ describe('Actions Notes Store', () => {
describe('deleteNote', () => {
const endpoint = `${TEST_HOST}/note`;
let axiosMock;
beforeEach(() => {
axiosMock = new AxiosMockAdapter(axios);
axiosMock.onDelete(endpoint).replyOnce(200, {});
$('body').attr('data-page', '');
......@@ -454,20 +418,9 @@ describe('Actions Notes Store', () => {
id: 1,
valid: true,
};
const interceptor = (request, next) => {
next(
request.respondWith(JSON.stringify(res), {
status: 200,
}),
);
};
beforeEach(() => {
Vue.http.interceptors.push(interceptor);
});
afterEach(() => {
Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor);
axiosMock.onAny().reply(200, res);
});
it('commits ADD_NEW_NOTE and dispatches updateMergeRequestWidget', done => {
......@@ -501,20 +454,9 @@ describe('Actions Notes Store', () => {
const res = {
errors: ['error'],
};
const interceptor = (request, next) => {
next(
request.respondWith(JSON.stringify(res), {
status: 200,
}),
);
};
beforeEach(() => {
Vue.http.interceptors.push(interceptor);
});
afterEach(() => {
Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor);
axiosMock.onAny().replyOnce(200, res);
});
it('does not commit ADD_NEW_NOTE or dispatch updateMergeRequestWidget', done => {
......@@ -534,20 +476,9 @@ describe('Actions Notes Store', () => {
const res = {
resolved: true,
};
const interceptor = (request, next) => {
next(
request.respondWith(JSON.stringify(res), {
status: 200,
}),
);
};
beforeEach(() => {
Vue.http.interceptors.push(interceptor);
});
afterEach(() => {
Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor);
axiosMock.onAny().reply(200, res);
});
describe('as note', () => {
......@@ -720,32 +651,19 @@ describe('Actions Notes Store', () => {
});
describe('replyToDiscussion', () => {
let res = { discussion: { notes: [] } };
const payload = { endpoint: TEST_HOST, data: {} };
const interceptor = (request, next) => {
next(
request.respondWith(JSON.stringify(res), {
status: 200,
}),
);
};
beforeEach(() => {
Vue.http.interceptors.push(interceptor);
});
afterEach(() => {
Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor);
});
it('updates discussion if response contains disussion', done => {
const discussion = { notes: [] };
axiosMock.onAny().reply(200, { discussion });
testAction(
actions.replyToDiscussion,
payload,
{
notesById: {},
},
[{ type: mutationTypes.UPDATE_DISCUSSION, payload: res.discussion }],
[{ type: mutationTypes.UPDATE_DISCUSSION, payload: discussion }],
[
{ type: 'updateMergeRequestWidget' },
{ type: 'startTaskList' },
......@@ -756,7 +674,8 @@ describe('Actions Notes Store', () => {
});
it('adds a reply to a discussion', done => {
res = {};
const res = {};
axiosMock.onAny().reply(200, res);
testAction(
actions.replyToDiscussion,
......
import Vue from 'vue';
import component from '~/releases/components/release_block.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import mountComponent from '../../helpers/vue_mount_component_helper';
describe('Release block', () => {
const Component = Vue.extend(component);
const release = {
name: 'Bionic Beaver',
tag_name: '18.04',
description: '## changelog\n\n* line 1\n* line2',
description_html: '<div><h2>changelog</h2><ul><li>line1</li<li>line 2</li></ul></div>',
author_name: 'Release bot',
author_email: 'release-bot@example.com',
released_at: '2012-05-28T05:00:00-07:00',
author: {
avatar_url: 'uploads/-/system/user/avatar/johndoe/avatar.png',
id: 482476,
name: 'John Doe',
path: '/johndoe',
state: 'active',
status_tooltip_html: null,
username: 'johndoe',
web_url: 'https://gitlab.com/johndoe',
},
commit: {
id: '2695effb5807a22ff3d138d593fd856244e155e7',
short_id: '2695effb',
title: 'Initial commit',
created_at: '2017-07-26T11:08:53.000+02:00',
parent_ids: ['2a4b78934375d7f53875269ffd4f45fd83a84ebe'],
message: 'Initial commit',
author_name: 'John Smith',
author_email: 'john@example.com',
authored_date: '2012-05-28T04:42:42-07:00',
committer_name: 'Jack Smith',
committer_email: 'jack@example.com',
committed_date: '2012-05-28T04:42:42-07:00',
},
assets: {
count: 6,
sources: [
{
format: 'zip',
url: 'https://gitlab.com/gitlab-org/gitlab-ce/-/archive/v11.3.12/gitlab-ce-v11.3.12.zip',
},
{
format: 'tar.gz',
url:
'https://gitlab.com/gitlab-org/gitlab-ce/-/archive/v11.3.12/gitlab-ce-v11.3.12.tar.gz',
},
{
format: 'tar.bz2',
url:
'https://gitlab.com/gitlab-org/gitlab-ce/-/archive/v11.3.12/gitlab-ce-v11.3.12.tar.bz2',
},
{
format: 'tar',
url: 'https://gitlab.com/gitlab-org/gitlab-ce/-/archive/v11.3.12/gitlab-ce-v11.3.12.tar',
},
],
links: [
{
name: 'release-18.04.dmg',
url: 'https://my-external-hosting.example.com/scrambled-url/',
external: true,
},
{
name: 'binary-linux-amd64',
url:
'https://gitlab.com/gitlab-org/gitlab-ce/-/jobs/artifacts/v11.6.0-rc4/download?job=rspec-mysql+41%2F50',
external: false,
},
],
},
};
let vm;
const factory = props => mountComponent(Component, { release: props });
beforeEach(() => {
vm = factory(release);
});
afterEach(() => {
vm.$destroy();
});
it("renders the block with an id equal to the release's tag name", () => {
expect(vm.$el.id).toBe('18.04');
});
it('renders release name', () => {
expect(vm.$el.textContent).toContain(release.name);
});
it('renders commit sha', () => {
expect(vm.$el.textContent).toContain(release.commit.short_id);
});
it('renders tag name', () => {
expect(vm.$el.textContent).toContain(release.tag_name);
});
it('renders release date', () => {
expect(vm.$el.textContent).toContain(timeagoMixin.methods.timeFormated(release.released_at));
});
it('renders number of assets provided', () => {
expect(vm.$el.querySelector('.js-assets-count').textContent).toContain(release.assets.count);
});
it('renders dropdown with the sources', () => {
expect(vm.$el.querySelectorAll('.js-sources-dropdown li').length).toEqual(
release.assets.sources.length,
);
expect(vm.$el.querySelector('.js-sources-dropdown li a').getAttribute('href')).toEqual(
release.assets.sources[0].url,
);
expect(vm.$el.querySelector('.js-sources-dropdown li a').textContent).toContain(
release.assets.sources[0].format,
);
});
it('renders list with the links provided', () => {
expect(vm.$el.querySelectorAll('.js-assets-list li').length).toEqual(
release.assets.links.length,
);
expect(vm.$el.querySelector('.js-assets-list li a').getAttribute('href')).toEqual(
release.assets.links[0].url,
);
expect(vm.$el.querySelector('.js-assets-list li a').textContent).toContain(
release.assets.links[0].name,
);
});
it('renders author avatar', () => {
expect(vm.$el.querySelector('.user-avatar-link')).not.toBeNull();
});
describe('external label', () => {
it('renders external label when link is external', () => {
expect(vm.$el.querySelector('.js-assets-list li a').textContent).toContain('external source');
});
it('does not render external label when link is not external', () => {
expect(vm.$el.querySelector('.js-assets-list li:nth-child(2) a').textContent).not.toContain(
'external source',
);
});
});
describe('with upcoming_release flag', () => {
beforeEach(() => {
vm = factory(Object.assign({}, release, { upcoming_release: true }));
});
it('renders upcoming release badge', () => {
expect(vm.$el.textContent).toContain('Upcoming Release');
});
});
});
# frozen_string_literal: true
require 'spec_helper'
describe Issues::ZoomLinkService do
set(:user) { create(:user) }
set(:issue) { create(:issue) }
let(:project) { issue.project }
let(:service) { described_class.new(issue, user) }
let(:zoom_link) { 'https://zoom.us/j/123456789' }
before do
project.add_reporter(user)
end
shared_context 'with Zoom link' do
before do
issue.update!(description: "Description\n\n#{zoom_link}")
end
end
shared_context 'with Zoom link not at the end' do
before do
issue.update!(description: "Description with #{zoom_link} some where")
end
end
shared_context 'without Zoom link' do
before do
issue.update!(description: "Description\n\nhttp://example.com")
end
end
shared_context 'without issue description' do
before do
issue.update!(description: nil)
end
end
shared_context 'feature flag disabled' do
before do
stub_feature_flags(issue_zoom_integration: false)
end
end
shared_context 'insufficient permissions' do
before do
project.add_guest(user)
end
end
describe '#add_link' do
shared_examples 'can add link' do
it 'appends the link to issue description' do
expect(result).to be_success
expect(result.payload[:description])
.to eq("#{issue.description}\n\n#{zoom_link}")
end
end
shared_examples 'cannot add link' do
it 'cannot add the link' do
expect(result).to be_error
expect(result.message).to eq('Failed to add a Zoom meeting')
end
end
subject(:result) { service.add_link(zoom_link) }
context 'without Zoom link in the issue description' do
include_context 'without Zoom link'
include_examples 'can add link'
context 'with invalid Zoom link' do
let(:zoom_link) { 'https://not-zoom.link' }
include_examples 'cannot add link'
end
context 'when feature flag is disabled' do
include_context 'feature flag disabled'
include_examples 'cannot add link'
end
context 'with insufficient permissions' do
include_context 'insufficient permissions'
include_examples 'cannot add link'
end
end
context 'with Zoom link in the issue description' do
include_context 'with Zoom link'
include_examples 'cannot add link'
context 'but not at the end' do
include_context 'with Zoom link not at the end'
include_examples 'can add link'
end
end
context 'without issue description' do
include_context 'without issue description'
include_examples 'can add link'
end
end
describe '#can_add_link?' do
subject { service.can_add_link? }
context 'without Zoom link in the issue description' do
include_context 'without Zoom link'
it { is_expected.to eq(true) }
context 'when feature flag is disabled' do
include_context 'feature flag disabled'
it { is_expected.to eq(false) }
end
context 'with insufficient permissions' do
include_context 'insufficient permissions'
it { is_expected.to eq(false) }
end
end
context 'with Zoom link in the issue description' do
include_context 'with Zoom link'
it { is_expected.to eq(false) }
end
end
describe '#remove_link' do
shared_examples 'cannot remove link' do
it 'cannot remove the link' do
expect(result).to be_error
expect(result.message).to eq('Failed to remove a Zoom meeting')
end
end
subject(:result) { service.remove_link }
context 'with Zoom link in the issue description' do
include_context 'with Zoom link'
it 'removes the link from the issue description' do
expect(result).to be_success
expect(result.payload[:description])
.to eq(issue.description.delete_suffix("\n\n#{zoom_link}"))
end
context 'when feature flag is disabled' do
include_context 'feature flag disabled'
include_examples 'cannot remove link'
end
context 'with insufficient permissions' do
include_context 'insufficient permissions'
include_examples 'cannot remove link'
end
context 'but not at the end' do
include_context 'with Zoom link not at the end'
include_examples 'cannot remove link'
end
end
context 'without Zoom link in the issue description' do
include_context 'without Zoom link'
include_examples 'cannot remove link'
end
context 'without issue description' do
include_context 'without issue description'
include_examples 'cannot remove link'
end
end
describe '#can_remove_link?' do
subject { service.can_remove_link? }
context 'with Zoom link in the issue description' do
include_context 'with Zoom link'
it { is_expected.to eq(true) }
context 'when feature flag is disabled' do
include_context 'feature flag disabled'
it { is_expected.to eq(false) }
end
context 'with insufficient permissions' do
include_context 'insufficient permissions'
it { is_expected.to eq(false) }
end
end
context 'without Zoom link in the issue description' do
include_context 'without Zoom link'
it { is_expected.to eq(false) }
end
end
describe '#parse_link' do
subject { service.parse_link(description) }
context 'with valid Zoom links' do
where(:description) do
[
'Some text https://zoom.us/j/123456789 more text',
'Mixed https://zoom.us/j/123456789 http://example.com',
'Multiple link https://zoom.us/my/name https://zoom.us/j/123456789'
]
end
with_them do
it { is_expected.to eq('https://zoom.us/j/123456789') }
end
end
context 'with invalid Zoom links' do
where(:description) do
[
nil,
'',
'Text only',
'Non-Zoom http://example.com',
'Almost Zoom http://zoom.us'
]
end
with_them do
it { is_expected.to eq(nil) }
end
end
end
end
# frozen_string_literal: true
shared_examples 'zoom quick actions' do
let(:zoom_link) { 'https://zoom.us/j/123456789' }
let(:invalid_zoom_link) { 'https://invalid-zoom' }
before do
issue.update!(description: description)
end
describe '/zoom' do
shared_examples 'skip silently' do
it 'skip addition silently' do
add_note("/zoom #{zoom_link}")
wait_for_requests
expect(page).not_to have_content('Zoom meeting added')
expect(page).not_to have_content('Failed to add a Zoom meeting')
expect(issue.reload.description).to eq(description)
end
end
shared_examples 'success' do
it 'adds a Zoom link' do
add_note("/zoom #{zoom_link}")
wait_for_requests
expect(page).to have_content('Zoom meeting added')
expect(issue.reload.description).to end_with(zoom_link)
end
end
context 'without issue description' do
let(:description) { nil }
include_examples 'success'
it 'cannot add invalid zoom link' do
add_note("/zoom #{invalid_zoom_link}")
wait_for_requests
expect(page).to have_content('Failed to add a Zoom meeting')
expect(page).not_to have_content(zoom_link)
end
context 'when feature flag disabled' do
before do
stub_feature_flags(issue_zoom_integration: false)
end
include_examples 'skip silently'
end
end
context 'with Zoom link not at the end of the issue description' do
let(:description) { "A link #{zoom_link} not at the end" }
include_examples 'success'
end
context 'with Zoom link at end of the issue description' do
let(:description) { "Text\n#{zoom_link}" }
include_examples 'skip silently'
end
end
describe '/remove_zoom' do
shared_examples 'skip silently' do
it 'skip removal silently' do
add_note('/remove_zoom')
wait_for_requests
expect(page).not_to have_content('Zoom meeting removed')
expect(page).not_to have_content('Failed to remove a Zoom meeting')
expect(issue.reload.description).to eq(description)
end
end
context 'with Zoom link in the description' do
let(:description) { "Text with #{zoom_link}\n\n\n#{zoom_link}" }
it 'removes last Zoom link' do
add_note('/remove_zoom')
wait_for_requests
expect(page).to have_content('Zoom meeting removed')
expect(issue.reload.description).to eq("Text with #{zoom_link}")
end
context 'when feature flag disabled' do
before do
stub_feature_flags(issue_zoom_integration: false)
end
include_examples 'skip silently'
end
end
context 'with a Zoom link not at the end of the description' do
let(:description) { "A link #{zoom_link} not at the end" }
include_examples 'skip silently'
end
end
end
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