summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2019-09-14 00:06:25 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2019-09-14 00:06:25 +0000
commita93dfc1b7e55b118b1cf4a67afeb46556292914c (patch)
tree65b874b7940d0d05c4ebedaef43b8a1009362651
parent188a57f93bba5953800de490fcc6246966a073fd (diff)
downloadgitlab-ce-a93dfc1b7e55b118b1cf4a67afeb46556292914c.tar.gz
Add latest changes from gitlab-org/gitlab@master
-rw-r--r--app/assets/javascripts/notes/services/notes_service.js19
-rw-r--r--app/assets/javascripts/notes/stores/actions.js126
-rw-r--r--app/assets/javascripts/releases/components/milestone_list.vue45
-rw-r--r--app/assets/javascripts/releases/components/release_block.vue22
-rw-r--r--app/services/issues/zoom_link_service.rb90
-rw-r--r--changelogs/unreleased/remove-vue-resource-from-notes-service.yml5
-rw-r--r--doc/user/project/quick_actions.md2
-rw-r--r--jest.config.js3
-rw-r--r--lib/gitlab/quick_actions/issue_actions.rb43
-rw-r--r--locale/gitlab.pot21
-rw-r--r--qa/qa/specs/features/browser_ui/2_plan/issue/close_issue_spec.rb1
-rw-r--r--qa/qa/specs/features/browser_ui/2_plan/issue/mentions_spec.rb2
-rw-r--r--spec/features/issues/user_uses_quick_actions_spec.rb1
-rw-r--r--spec/frontend/notes/components/note_app_spec.js35
-rw-r--r--spec/frontend/releases/components/milestone_list_spec.js56
-rw-r--r--spec/frontend/releases/components/release_block_spec.js120
-rw-r--r--spec/frontend/releases/mock_data.js97
-rw-r--r--spec/javascripts/notes/mock_data.js20
-rw-r--r--spec/javascripts/notes/stores/actions_spec.js121
-rw-r--r--spec/javascripts/releases/components/release_block_spec.js168
-rw-r--r--spec/services/issues/zoom_link_service_spec.rb243
-rw-r--r--spec/support/shared_examples/quick_actions/issue/zoom_quick_actions_shared_examples.rb111
22 files changed, 961 insertions, 390 deletions
diff --git a/app/assets/javascripts/notes/services/notes_service.js b/app/assets/javascripts/notes/services/notes_service.js
index 3d239d8cb6b..4d3dbec435f 100644
--- a/app/assets/javascripts/notes/services/notes_service.js
+++ b/app/assets/javascripts/notes/services/notes_service.js
@@ -1,31 +1,28 @@
-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);
},
};
diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js
index 411bd585672..6c236981a24 100644
--- a/app/assets/javascripts/notes/stores/actions.js
+++ b/app/assets/javascripts/notes/stores/actions.js
@@ -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.')));
};
diff --git a/app/assets/javascripts/releases/components/milestone_list.vue b/app/assets/javascripts/releases/components/milestone_list.vue
new file mode 100644
index 00000000000..53416f0ab4d
--- /dev/null
+++ b/app/assets/javascripts/releases/components/milestone_list.vue
@@ -0,0 +1,45 @@
+<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>
diff --git a/app/assets/javascripts/releases/components/release_block.vue b/app/assets/javascripts/releases/components/release_block.vue
index 88b6b4732b1..2dacd8549ad 100644
--- a/app/assets/javascripts/releases/components/release_block.vue
+++ b/app/assets/javascripts/releases/components/release_block.vue
@@ -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)">
diff --git a/app/services/issues/zoom_link_service.rb b/app/services/issues/zoom_link_service.rb
new file mode 100644
index 00000000000..a061ab22875
--- /dev/null
+++ b/app/services/issues/zoom_link_service.rb
@@ -0,0 +1,90 @@
+# 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
diff --git a/changelogs/unreleased/remove-vue-resource-from-notes-service.yml b/changelogs/unreleased/remove-vue-resource-from-notes-service.yml
new file mode 100644
index 00000000000..047bebb5402
--- /dev/null
+++ b/changelogs/unreleased/remove-vue-resource-from-notes-service.yml
@@ -0,0 +1,5 @@
+---
+title: Remove vue-resource from notes service
+merge_request: 32934
+author: Lee Tickett
+type: other
diff --git a/doc/user/project/quick_actions.md b/doc/user/project/quick_actions.md
index 1f7f85e9750..a1c65ddea76 100644
--- a/doc/user/project/quick_actions.md
+++ b/doc/user/project/quick_actions.md
@@ -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 |
diff --git a/jest.config.js b/jest.config.js
index e4ac71a1a17..646648c6928 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -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',
diff --git a/lib/gitlab/quick_actions/issue_actions.rb b/lib/gitlab/quick_actions/issue_actions.rb
index 869627ac585..7e64fe2a1f4 100644
--- a/lib/gitlab/quick_actions/issue_actions.rb
+++ b/lib/gitlab/quick_actions/issue_actions.rb
@@ -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
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 2d549861893..7a0f10c83d2 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -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 ""
diff --git a/qa/qa/specs/features/browser_ui/2_plan/issue/close_issue_spec.rb b/qa/qa/specs/features/browser_ui/2_plan/issue/close_issue_spec.rb
index 7b6a3579af0..83cf164ccb0 100644
--- a/qa/qa/specs/features/browser_ui/2_plan/issue/close_issue_spec.rb
+++ b/qa/qa/specs/features/browser_ui/2_plan/issue/close_issue_spec.rb
@@ -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
diff --git a/qa/qa/specs/features/browser_ui/2_plan/issue/mentions_spec.rb b/qa/qa/specs/features/browser_ui/2_plan/issue/mentions_spec.rb
index b68a24ec538..925c601f869 100644
--- a/qa/qa/specs/features/browser_ui/2_plan/issue/mentions_spec.rb
+++ b/qa/qa/specs/features/browser_ui/2_plan/issue/mentions_spec.rb
@@ -1,7 +1,7 @@
# 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|
diff --git a/spec/features/issues/user_uses_quick_actions_spec.rb b/spec/features/issues/user_uses_quick_actions_spec.rb
index 26979e943d0..09f07f8c908 100644
--- a/spec/features/issues/user_uses_quick_actions_spec.rb
+++ b/spec/features/issues/user_uses_quick_actions_spec.rb
@@ -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
diff --git a/spec/frontend/notes/components/note_app_spec.js b/spec/frontend/notes/components/note_app_spec.js
index 02fd30d5a15..d2c17310e9c 100644
--- a/spec/frontend/notes/components/note_app_spec.js
+++ b/spec/frontend/notes/components/note_app_spec.js
@@ -1,4 +1,6 @@
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();
});
diff --git a/spec/frontend/releases/components/milestone_list_spec.js b/spec/frontend/releases/components/milestone_list_spec.js
new file mode 100644
index 00000000000..f267177ddab
--- /dev/null
+++ b/spec/frontend/releases/components/milestone_list_spec.js
@@ -0,0 +1,56 @@
+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);
+ });
+});
diff --git a/spec/frontend/releases/components/release_block_spec.js b/spec/frontend/releases/components/release_block_spec.js
new file mode 100644
index 00000000000..4be5d500fd9
--- /dev/null
+++ b/spec/frontend/releases/components/release_block_spec.js
@@ -0,0 +1,120 @@
+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');
+ });
+});
diff --git a/spec/frontend/releases/mock_data.js b/spec/frontend/releases/mock_data.js
new file mode 100644
index 00000000000..a0885813c7e
--- /dev/null
+++ b/spec/frontend/releases/mock_data.js
@@ -0,0 +1,97 @@
+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,
+ },
+ ],
+ },
+};
diff --git a/spec/javascripts/notes/mock_data.js b/spec/javascripts/notes/mock_data.js
index f0e58cbda4d..98a9150d05d 100644
--- a/spec/javascripts/notes/mock_data.js
+++ b/spec/javascripts/notes/mock_data.js
@@ -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 = [
diff --git a/spec/javascripts/notes/stores/actions_spec.js b/spec/javascripts/notes/stores/actions_spec.js
index 1fd4a9a7612..e3cc025cf49 100644
--- a/spec/javascripts/notes/stores/actions_spec.js
+++ b/spec/javascripts/notes/stores/actions_spec.js
@@ -1,9 +1,6 @@
-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,
diff --git a/spec/javascripts/releases/components/release_block_spec.js b/spec/javascripts/releases/components/release_block_spec.js
deleted file mode 100644
index fdf23f3f69d..00000000000
--- a/spec/javascripts/releases/components/release_block_spec.js
+++ /dev/null
@@ -1,168 +0,0 @@
-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');
- });
- });
-});
diff --git a/spec/services/issues/zoom_link_service_spec.rb b/spec/services/issues/zoom_link_service_spec.rb
new file mode 100644
index 00000000000..baa6d774864
--- /dev/null
+++ b/spec/services/issues/zoom_link_service_spec.rb
@@ -0,0 +1,243 @@
+# 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
diff --git a/spec/support/shared_examples/quick_actions/issue/zoom_quick_actions_shared_examples.rb b/spec/support/shared_examples/quick_actions/issue/zoom_quick_actions_shared_examples.rb
new file mode 100644
index 00000000000..cb5460bde23
--- /dev/null
+++ b/spec/support/shared_examples/quick_actions/issue/zoom_quick_actions_shared_examples.rb
@@ -0,0 +1,111 @@
+# 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