diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2019-09-14 00:06:25 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2019-09-14 00:06:25 +0000 |
commit | a93dfc1b7e55b118b1cf4a67afeb46556292914c (patch) | |
tree | 65b874b7940d0d05c4ebedaef43b8a1009362651 | |
parent | 188a57f93bba5953800de490fcc6246966a073fd (diff) | |
download | gitlab-ce-a93dfc1b7e55b118b1cf4a67afeb46556292914c.tar.gz |
Add latest changes from gitlab-org/gitlab@master
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"> + • + </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"> • <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 |