diff options
author | Kushal Pandya <kushalspandya@gmail.com> | 2019-04-07 02:36:21 +0000 |
---|---|---|
committer | Kushal Pandya <kushalspandya@gmail.com> | 2019-04-07 02:36:21 +0000 |
commit | 8f372152774119440ba41f2603eac4f475a601f6 (patch) | |
tree | c89f98207d31ee7ec53f403f3b2dd368f6d84a99 | |
parent | 302dbfff2a298a2683997feca98589aff33228fb (diff) | |
parent | 7650677d3d832f9d65c8d38a2485ca60b97731c4 (diff) | |
download | gitlab-ce-8f372152774119440ba41f2603eac4f475a601f6.tar.gz |
Merge branch '_acet-related-mrs-widget-rewrite' into 'master'
Rewrite related MRs widget with Vue
Closes #58926 and #57662
See merge request gitlab-org/gitlab-ce!27027
24 files changed, 651 insertions, 70 deletions
diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js index b3508f36cf9..cd1afb6ba83 100644 --- a/app/assets/javascripts/issue.js +++ b/app/assets/javascripts/issue.js @@ -15,7 +15,6 @@ export default class Issue { Issue.$btnNewBranch = $('#new-branch'); Issue.createMrDropdownWrap = document.querySelector('.create-mr-dropdown-wrap'); - Issue.initMergeRequests(); if (document.querySelector('#related-branches')) { Issue.initRelatedBranches(); } @@ -143,19 +142,6 @@ export default class Issue { } } - static initMergeRequests() { - var $container; - $container = $('#merge-requests'); - return axios - .get($container.data('url')) - .then(({ data }) => { - if ('html' in data) { - $container.html(data.html); - } - }) - .catch(() => flash('Failed to load referenced merge requests')); - } - static initRelatedBranches() { var $container; $container = $('#related-branches'); diff --git a/app/assets/javascripts/issue_show/index.js b/app/assets/javascripts/issue_show/index.js index d08e8ba0c4b..529b6386221 100644 --- a/app/assets/javascripts/issue_show/index.js +++ b/app/assets/javascripts/issue_show/index.js @@ -1,12 +1,9 @@ import Vue from 'vue'; -import sanitize from 'sanitize-html'; import issuableApp from './components/app.vue'; +import { parseIssuableData } from './utils/parse_data'; import '../vue_shared/vue_resource_interceptor'; export default function initIssueableApp() { - const initialDataEl = document.getElementById('js-issuable-app-initial-data'); - const props = JSON.parse(sanitize(initialDataEl.textContent).replace(/"/g, '"')); - return new Vue({ el: document.getElementById('js-issuable-app'), components: { @@ -14,7 +11,7 @@ export default function initIssueableApp() { }, render(createElement) { return createElement('issuable-app', { - props, + props: parseIssuableData(), }); }, }); diff --git a/app/assets/javascripts/issue_show/utils/parse_data.js b/app/assets/javascripts/issue_show/utils/parse_data.js new file mode 100644 index 00000000000..05e384adad3 --- /dev/null +++ b/app/assets/javascripts/issue_show/utils/parse_data.js @@ -0,0 +1,15 @@ +import sanitize from 'sanitize-html'; + +export const parseIssuableData = () => { + try { + const initialDataEl = document.getElementById('js-issuable-app-initial-data'); + + return JSON.parse(sanitize(initialDataEl.textContent).replace(/"/g, '"')); + } catch (e) { + console.error(e); // eslint-disable-line no-console + + return {}; + } +}; + +export default {}; diff --git a/app/assets/javascripts/pages/projects/issues/show.js b/app/assets/javascripts/pages/projects/issues/show.js index 8987c8e3f47..0447d1f79fb 100644 --- a/app/assets/javascripts/pages/projects/issues/show.js +++ b/app/assets/javascripts/pages/projects/issues/show.js @@ -4,9 +4,11 @@ import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable'; import ZenMode from '~/zen_mode'; import '~/notes/index'; import initIssueableApp from '~/issue_show'; +import initRelatedMergeRequestsApp from '~/related_merge_requests'; export default function() { initIssueableApp(); + initRelatedMergeRequestsApp(); new Issue(); // eslint-disable-line no-new new ShortcutsIssuable(); // eslint-disable-line no-new new ZenMode(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue b/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue new file mode 100644 index 00000000000..52d4b75a3a1 --- /dev/null +++ b/app/assets/javascripts/related_merge_requests/components/related_merge_requests.vue @@ -0,0 +1,121 @@ +<script> +import { mapState, mapActions } from 'vuex'; +import { GlLoadingIcon } from '@gitlab/ui'; +import { sprintf, n__, s__ } from '~/locale'; +import Icon from '~/vue_shared/components/icon.vue'; +import RelatedIssuableItem from '~/vue_shared/components/issue/related_issuable_item.vue'; +import { parseIssuableData } from '../../issue_show/utils/parse_data'; + +export default { + name: 'RelatedMergeRequests', + components: { + Icon, + GlLoadingIcon, + RelatedIssuableItem, + }, + props: { + endpoint: { + type: String, + required: true, + }, + projectNamespace: { + type: String, + required: true, + }, + projectPath: { + type: String, + required: true, + }, + }, + computed: { + ...mapState(['isFetchingMergeRequests', 'mergeRequests', 'totalCount']), + closingMergeRequestsText() { + if (!this.hasClosingMergeRequest) { + return ''; + } + + const mrText = n__( + 'When this merge request is accepted', + 'When these merge requests are accepted', + this.totalCount, + ); + + return sprintf(s__('%{mrText}, this issue will be closed automatically.'), { mrText }); + }, + }, + mounted() { + this.setInitialState({ apiEndpoint: this.endpoint }); + this.fetchMergeRequests(); + }, + created() { + this.hasClosingMergeRequest = parseIssuableData().hasClosingMergeRequest; + }, + methods: { + ...mapActions(['setInitialState', 'fetchMergeRequests']), + getAssignees(mr) { + if (mr.assignees) { + return mr.assignees; + } + + return mr.assignee ? [mr.assignee] : []; + }, + }, +}; +</script> + +<template> + <div v-if="isFetchingMergeRequests || (!isFetchingMergeRequests && totalCount)"> + <div id="merge-requests" class="card-slim mt-3"> + <div class="card-header"> + <div class="card-title mt-0 mb-0 h5 merge-requests-title"> + <span class="mr-1"> + {{ __('Related merge requests') }} + </span> + <div v-if="totalCount" class="d-inline-flex lh-100 align-middle"> + <div class="mr-count-badge"> + <div class="mr-count-badge-count"> + <svg class="s16 mr-1 text-secondary"> + <icon name="merge-request" class="mr-1 text-secondary" /> + </svg> + <span class="js-items-count">{{ totalCount }}</span> + </div> + </div> + </div> + </div> + </div> + <div> + <div + v-if="isFetchingMergeRequests" + class="related-related-merge-requests-icon qa-related-merge-requests-loading-icon" + > + <gl-loading-icon label="Fetching related merge requests" class="py-2" /> + </div> + <ul v-else class="content-list related-items-list"> + <li v-for="mr in mergeRequests" :key="mr.id" class="list-item pt-0 pb-0"> + <related-issuable-item + :id-key="mr.id" + :display-reference="mr.reference" + :title="mr.title" + :milestone="mr.milestone" + :assignees="getAssignees(mr)" + :created-at="mr.created_at" + :closed-at="mr.closed_at" + :merged-at="mr.merged_at" + :path="mr.web_url" + :state="mr.state" + :is-merge-request="true" + :pipeline-status="mr.head_pipeline && mr.head_pipeline.detailed_status" + path-id-separator="!" + /> + </li> + </ul> + </div> + </div> + <div + v-if="hasClosingMergeRequest && !isFetchingMergeRequests" + class="issue-closed-by-widget second-block" + > + {{ closingMergeRequestsText }} + </div> + </div> +</template> diff --git a/app/assets/javascripts/related_merge_requests/index.js b/app/assets/javascripts/related_merge_requests/index.js new file mode 100644 index 00000000000..092ff1df00f --- /dev/null +++ b/app/assets/javascripts/related_merge_requests/index.js @@ -0,0 +1,24 @@ +import Vue from 'vue'; +import RelatedMergeRequests from './components/related_merge_requests.vue'; +import createStore from './store'; + +export default function initRelatedMergeRequests() { + const relatedMergeRequestsElement = document.querySelector('#js-related-merge-requests'); + + if (relatedMergeRequestsElement) { + const { endpoint, projectPath, projectNamespace } = relatedMergeRequestsElement.dataset; + + // eslint-disable-next-line no-new + new Vue({ + el: relatedMergeRequestsElement, + components: { + RelatedMergeRequests, + }, + store: createStore(), + render: createElement => + createElement('related-merge-requests', { + props: { endpoint, projectNamespace, projectPath }, + }), + }); + } +} diff --git a/app/assets/javascripts/related_merge_requests/store/actions.js b/app/assets/javascripts/related_merge_requests/store/actions.js new file mode 100644 index 00000000000..69abeaaf7db --- /dev/null +++ b/app/assets/javascripts/related_merge_requests/store/actions.js @@ -0,0 +1,37 @@ +import axios from '~/lib/utils/axios_utils'; +import createFlash from '~/flash'; +import { s__ } from '~/locale'; +import { normalizeHeaders } from '~/lib/utils/common_utils'; +import * as types from './mutation_types'; + +const REQUEST_PAGE_COUNT = 100; + +export const setInitialState = ({ commit }, props) => { + commit(types.SET_INITIAL_STATE, props); +}; + +export const requestData = ({ commit }) => commit(types.REQUEST_DATA); + +export const receiveDataSuccess = ({ commit }, data) => commit(types.RECEIVE_DATA_SUCCESS, data); + +export const receiveDataError = ({ commit }) => commit(types.RECEIVE_DATA_ERROR); + +export const fetchMergeRequests = ({ state, dispatch }) => { + dispatch('requestData'); + + return axios + .get(`${state.apiEndpoint}?per_page=${REQUEST_PAGE_COUNT}`) + .then(res => { + const { headers, data } = res; + const total = Number(normalizeHeaders(headers)['X-TOTAL']) || 0; + + dispatch('receiveDataSuccess', { data, total }); + }) + .catch(() => { + dispatch('receiveDataError'); + createFlash(s__('Something went wrong while fetching related merge requests.')); + }); +}; + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/related_merge_requests/store/index.js b/app/assets/javascripts/related_merge_requests/store/index.js new file mode 100644 index 00000000000..dcb70c22bcb --- /dev/null +++ b/app/assets/javascripts/related_merge_requests/store/index.js @@ -0,0 +1,14 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import createState from './state'; +import * as actions from './actions'; +import mutations from './mutations'; + +Vue.use(Vuex); + +export default () => + new Vuex.Store({ + state: createState(), + actions, + mutations, + }); diff --git a/app/assets/javascripts/related_merge_requests/store/mutation_types.js b/app/assets/javascripts/related_merge_requests/store/mutation_types.js new file mode 100644 index 00000000000..31d4fe032e1 --- /dev/null +++ b/app/assets/javascripts/related_merge_requests/store/mutation_types.js @@ -0,0 +1,4 @@ +export const SET_INITIAL_STATE = 'SET_INITIAL_STATE'; +export const REQUEST_DATA = 'REQUEST_DATA'; +export const RECEIVE_DATA_SUCCESS = 'RECEIVE_DATA_SUCCESS'; +export const RECEIVE_DATA_ERROR = 'RECEIVE_DATA_ERROR'; diff --git a/app/assets/javascripts/related_merge_requests/store/mutations.js b/app/assets/javascripts/related_merge_requests/store/mutations.js new file mode 100644 index 00000000000..11ca28a5fb9 --- /dev/null +++ b/app/assets/javascripts/related_merge_requests/store/mutations.js @@ -0,0 +1,19 @@ +import * as types from './mutation_types'; + +export default { + [types.SET_INITIAL_STATE](state, { apiEndpoint }) { + state.apiEndpoint = apiEndpoint; + }, + [types.REQUEST_DATA](state) { + state.isFetchingMergeRequests = true; + }, + [types.RECEIVE_DATA_SUCCESS](state, { data, total }) { + state.isFetchingMergeRequests = false; + state.mergeRequests = data; + state.totalCount = total; + }, + [types.RECEIVE_DATA_ERROR](state) { + state.isFetchingMergeRequests = false; + state.hasErrorFetchingMergeRequests = true; + }, +}; diff --git a/app/assets/javascripts/related_merge_requests/store/state.js b/app/assets/javascripts/related_merge_requests/store/state.js new file mode 100644 index 00000000000..bc3468a025b --- /dev/null +++ b/app/assets/javascripts/related_merge_requests/store/state.js @@ -0,0 +1,7 @@ +export default () => ({ + apiEndpoint: '', + isFetchingMergeRequests: false, + hasErrorFetchingMergeRequests: false, + mergeRequests: [], + totalCount: 0, +}); diff --git a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue index 27cfa8abb24..d4d18614f93 100644 --- a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue +++ b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue @@ -1,15 +1,17 @@ <script> import { GlTooltipDirective } from '@gitlab/ui'; -import { __, sprintf } from '~/locale'; -import IssueMilestone from '~/vue_shared/components/issue/issue_milestone.vue'; -import IssueAssignees from '~/vue_shared/components/issue/issue_assignees.vue'; -import relatedIssuableMixin from '~/vue_shared/mixins/related_issuable_mixin'; +import { sprintf } from '~/locale'; +import IssueMilestone from '../../components/issue/issue_milestone.vue'; +import IssueAssignees from '../../components/issue/issue_assignees.vue'; +import relatedIssuableMixin from '../../mixins/related_issuable_mixin'; +import CiIcon from '../ci_icon.vue'; export default { name: 'IssueItem', components: { IssueMilestone, IssueAssignees, + CiIcon, }, directives: { GlTooltip: GlTooltipDirective, @@ -27,9 +29,9 @@ export default { return sprintf( '<span class="bold">%{state}</span> %{timeInWords}<br/><span class="text-tertiary">%{timestamp}</span>', { - state: this.isOpen ? __('Opened') : __('Closed'), - timeInWords: this.isOpen ? this.createdAtInWords : this.closedAtInWords, - timestamp: this.isOpen ? this.createdAtTimestamp : this.closedAtTimestamp, + state: this.stateText, + timeInWords: this.stateTimeInWords, + timestamp: this.stateTimestamp, }, ); }, @@ -84,6 +86,11 @@ export default { {{ pathIdSeparator }}{{ itemId }} </div> <div class="item-meta-child d-flex align-items-center"> + <span v-if="hasPipeline" class="mr-ci-status pr-2"> + <a :href="pipelineStatus.details_path"> + <ci-icon v-gl-tooltip :status="pipelineStatus" :title="pipelineStatusTooltip" /> + </a> + </span> <issue-milestone v-if="hasMilestone" :milestone="milestone" diff --git a/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js b/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js index 455ae832234..8e0e4baa75a 100644 --- a/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js +++ b/app/assets/javascripts/vue_shared/mixins/related_issuable_mixin.js @@ -1,4 +1,5 @@ import _ from 'underscore'; +import { sprintf, __ } from '~/locale'; import { formatDate } from '~/lib/utils/datetime_utility'; import tooltip from '~/vue_shared/directives/tooltip'; import icon from '~/vue_shared/components/icon.vue'; @@ -58,6 +59,11 @@ const mixins = { required: false, default: '', }, + mergedAt: { + type: String, + required: false, + default: '', + }, milestone: { type: Object, required: false, @@ -83,6 +89,16 @@ const mixins = { required: false, default: false, }, + isMergeRequest: { + type: Boolean, + required: false, + default: false, + }, + pipelineStatus: { + type: Object, + required: false, + default: () => ({}), + }, }, components: { icon, @@ -95,12 +111,18 @@ const mixins = { hasState() { return this.state && this.state.length > 0; }, + hasPipeline() { + return this.isMergeRequest && this.pipelineStatus && Object.keys(this.pipelineStatus).length; + }, isOpen() { return this.state === 'opened'; }, isClosed() { return this.state === 'closed'; }, + isMerged() { + return this.state === 'merged'; + }, hasTitle() { return this.title.length > 0; }, @@ -108,9 +130,17 @@ const mixins = { return !_.isEmpty(this.milestone); }, iconName() { + if (this.isMergeRequest && this.isMerged) { + return 'merge'; + } + return this.isOpen ? 'issue-open-m' : 'issue-close'; }, iconClass() { + if (this.isMergeRequest && this.isClosed) { + return 'merge-request-status closed issue-token-state-icon-closed'; + } + return this.isOpen ? 'issue-token-state-icon-open' : 'issue-token-state-icon-closed'; }, computedLinkElementType() { @@ -131,12 +161,44 @@ const mixins = { createdAtTimestamp() { return this.createdAt ? formatDate(new Date(this.createdAt)) : ''; }, + mergedAtTimestamp() { + return this.mergedAt ? formatDate(new Date(this.mergedAt)) : ''; + }, + mergedAtInWords() { + return this.mergedAt ? this.timeFormated(this.mergedAt) : ''; + }, closedAtInWords() { return this.closedAt ? this.timeFormated(this.closedAt) : ''; }, closedAtTimestamp() { return this.closedAt ? formatDate(new Date(this.closedAt)) : ''; }, + stateText() { + if (this.isMerged) { + return __('Merged'); + } + + return this.isOpen ? __('Opened') : __('Closed'); + }, + stateTimeInWords() { + if (this.isMerged) { + return this.mergedAtInWords; + } + + return this.isOpen ? this.createdAtInWords : this.closedAtInWords; + }, + stateTimestamp() { + if (this.isMerged) { + return this.mergedAtTimestamp; + } + + return this.isOpen ? this.createdAtTimestamp : this.closedAtTimestamp; + }, + pipelineStatusTooltip() { + return this.hasPipeline + ? sprintf(__('Pipeline: %{status}'), { status: this.pipelineStatus.label }) + : ''; + }, }, methods: { onRemoveRequest() { diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 0622cdfc196..52c49498e9b 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -277,6 +277,8 @@ module IssuablesHelper initialTaskStatus: issuable.task_status } + data[:hasClosingMergeRequest] = issuable.merge_requests_count != 0 if issuable.is_a?(Issue) + if parent.is_a?(Group) data[:groupPath] = parent.path else diff --git a/app/views/projects/issues/_closed_by_box.html.haml b/app/views/projects/issues/_closed_by_box.html.haml deleted file mode 100644 index 38469ed4774..00000000000 --- a/app/views/projects/issues/_closed_by_box.html.haml +++ /dev/null @@ -1,4 +0,0 @@ -.issue-closed-by-widget.second-block - - pluralized_mr_this = merge_request_count > 1 ? "these" : "this" - - pluralized_mr_is = merge_request_count > 1 ? "are" : "is" - When #{pluralized_mr_this} merge #{"request".pluralize(merge_request_count)} #{pluralized_mr_is} accepted, this issue will be closed automatically. diff --git a/app/views/projects/issues/_merge_requests.html.haml b/app/views/projects/issues/_merge_requests.html.haml deleted file mode 100644 index 6a66c2e57cc..00000000000 --- a/app/views/projects/issues/_merge_requests.html.haml +++ /dev/null @@ -1,36 +0,0 @@ -- if @merge_requests.any? - .card-slim.mt-3 - .card-header - %h2.card-title.mt-0.mb-0.h5.merge-requests-title - %span.mr-1.bold - = _('Related merge requests') - .d-inline-flex.lh-100.align-middle - .mr-count-badge - .mr-count-badge-count - = sprite_icon('merge-request', size: 16, css_class: 'mr-1 text-secondary') - = @merge_requests.count - %ul.content-list.related-items-list - - has_any_head_pipeline = @merge_requests.any?(&:head_pipeline_id) - - @merge_requests.each do |merge_request| - - merge_request = merge_request.present(current_user: current_user) - %li.list-item.py-0.px-0 - .item-body.issuable-info-container.py-lg-3.px-lg-3.pl-md-3 - .item-contents - .item-title.d-flex.align-items-center.mr-title - = render partial: 'projects/issues/merge_requests_status', locals: { merge_request: merge_request, css_class: 'd-none d-xl-block append-right-8' } - = link_to merge_request.title, merge_request_path(merge_request), { class: 'mr-title-link'} - .item-meta - = render partial: 'projects/issues/merge_requests_status', locals: { merge_request: merge_request, css_class: 'd-xl-none d-lg-block append-right-5' } - %span.d-flex.align-items-center.append-right-8.mr-item-path.item-path-id.mt-0 - %span.path-id-text.bold.text-truncate{ data: { toggle: 'tooltip'}, title: merge_request.target_project.full_path } - = merge_request.target_project.full_path - = merge_request.to_reference - %span.mr-ci-status.flex-md-grow-1.justify-content-end.d-flex.ml-md-2 - - if merge_request.can_read_pipeline? - = render 'ci/status/icon', status: merge_request.head_pipeline.detailed_status(current_user), tooltip_placement: 'bottom' - - elsif has_any_head_pipeline - = icon('blank fw') - - - if @closed_by_merge_requests.present? - %p - = render partial: 'projects/issues/closed_by_box', locals: {merge_request_count: @merge_requests.count} diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index f8969fbb5a2..2c95ac6dbb3 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -77,12 +77,11 @@ = edited_time_ago_with_tooltip(@issue, placement: 'bottom', html_class: 'issue-edited-ago js-issue-edited-ago') - #merge-requests{ data: { url: referenced_merge_requests_project_issue_path(@project, @issue) } } - // This element is filled in using JavaScript. + #js-related-merge-requests{ data: { endpoint: api_v4_projects_issues_related_merge_requests_path(id: @project.id, issue_iid: @issue.iid), project_namespace: @project.namespace.path, project_path: @project.path } } - if can?(current_user, :download_code, @project) #related-branches{ data: { url: related_branches_project_issue_path(@project, @issue) } } - // This element is filled in using JavaScript. + -# This element is filled in using JavaScript. .content-block.emoji-block.emoji-block-sticky .row diff --git a/changelogs/unreleased/_acet-related-mrs-widget-rewrite.yml b/changelogs/unreleased/_acet-related-mrs-widget-rewrite.yml new file mode 100644 index 00000000000..b773eb2720c --- /dev/null +++ b/changelogs/unreleased/_acet-related-mrs-widget-rewrite.yml @@ -0,0 +1,5 @@ +--- +title: Rewrite related MRs widget with Vue +merge_request: 27027 +author: +type: other diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 711baff9f9a..3bc48e8c5d2 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -141,6 +141,9 @@ msgstr "" msgid "%{lock_path} is locked by GitLab User %{lock_user_id}" msgstr "" +msgid "%{mrText}, this issue will be closed automatically." +msgstr "" + msgid "%{number_commits_behind} commits behind %{default_branch}, %{number_commits_ahead} commits ahead" msgstr "" @@ -5830,6 +5833,9 @@ msgstr "" msgid "Pipeline triggers" msgstr "" +msgid "Pipeline: %{status}" +msgstr "" + msgid "PipelineCharts|Failed:" msgstr "" @@ -7510,6 +7516,9 @@ msgstr "" msgid "Something went wrong while fetching comments. Please try again." msgstr "" +msgid "Something went wrong while fetching related merge requests." +msgstr "" + msgid "Something went wrong while fetching the environments for this merge request. Please try again." msgstr "" @@ -9238,6 +9247,11 @@ msgstr "" msgid "When enabled, users cannot use GitLab until the terms have been accepted." msgstr "" +msgid "When this merge request is accepted" +msgid_plural "When these merge requests are accepted" +msgstr[0] "" +msgstr[1] "" + msgid "When:" msgstr "" diff --git a/spec/features/issuables/markdown_references/internal_references_spec.rb b/spec/features/issuables/markdown_references/internal_references_spec.rb index 23385ba65fc..870e92b8de8 100644 --- a/spec/features/issuables/markdown_references/internal_references_spec.rb +++ b/spec/features/issuables/markdown_references/internal_references_spec.rb @@ -70,7 +70,7 @@ describe "Internal references", :js do page.within("#merge-requests ul") do expect(page).to have_content(private_project_merge_request.title) - expect(page).to have_css(".merge-request-status") + expect(page).to have_css(".ic-issue-open-m") end expect(page).to have_content("mentioned in merge request #{private_project_merge_request.to_reference(public_project)}") diff --git a/spec/javascripts/fixtures/issues.rb b/spec/javascripts/fixtures/issues.rb index 645b3aa788a..0f3f9a10f94 100644 --- a/spec/javascripts/fixtures/issues.rb +++ b/spec/javascripts/fixtures/issues.rb @@ -65,3 +65,61 @@ describe Projects::IssuesController, '(JavaScript fixtures)', type: :controller store_frontend_fixture(response, fixture_file_name) end end + +describe API::Issues, '(JavaScript fixtures)', type: :request do + include ApiHelpers + include JavaScriptFixturesHelpers + + def get_related_merge_requests(project_id, issue_iid, user = nil) + get api("/projects/#{project_id}/issues/#{issue_iid}/related_merge_requests", user) + end + + def create_referencing_mr(user, project, issue) + attributes = { + author: user, + source_project: project, + target_project: project, + source_branch: "master", + target_branch: "test", + assignee: user, + description: "See #{issue.to_reference}" + } + create(:merge_request, attributes).tap do |merge_request| + create(:note, :system, project: issue.project, noteable: issue, author: user, note: merge_request.to_reference(full: true)) + end + end + + it 'issues/related_merge_requests.json' do |example| + user = create(:user) + project = create(:project, :public, creator_id: user.id, namespace: user.namespace) + issue_title = 'foo' + issue_description = 'closed' + milestone = create(:milestone, title: '1.0.0', project: project) + issue = create :issue, + author: user, + assignees: [user], + project: project, + milestone: milestone, + created_at: generate(:past_time), + updated_at: 1.hour.ago, + title: issue_title, + description: issue_description + + project.add_reporter(user) + create_referencing_mr(user, project, issue) + + create(:merge_request, + :simple, + author: user, + source_project: project, + target_project: project, + description: "Some description") + project2 = create(:project, :public, creator_id: user.id, namespace: user.namespace) + create_referencing_mr(user, project2, issue).update!(head_pipeline: create(:ci_pipeline)) + + get_related_merge_requests(project.id, issue.iid, user) + + expect(response).to be_success + store_frontend_fixture(response, example.description) + end +end diff --git a/spec/javascripts/related_merge_requests/components/related_merge_requests_spec.js b/spec/javascripts/related_merge_requests/components/related_merge_requests_spec.js new file mode 100644 index 00000000000..29760f79c3c --- /dev/null +++ b/spec/javascripts/related_merge_requests/components/related_merge_requests_spec.js @@ -0,0 +1,89 @@ +import { mount, createLocalVue } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import RelatedIssuableItem from '~/vue_shared/components/issue/related_issuable_item.vue'; +import RelatedMergeRequests from '~/related_merge_requests/components/related_merge_requests.vue'; +import createStore from '~/related_merge_requests/store/index'; + +const FIXTURE_PATH = 'issues/related_merge_requests.json'; +const API_ENDPOINT = '/api/v4/projects/2/issues/33/related_merge_requests'; +const localVue = createLocalVue(); + +describe('RelatedMergeRequests', () => { + let wrapper; + let mock; + let mockData; + + beforeEach(done => { + loadFixtures(FIXTURE_PATH); + mockData = getJSONFixture(FIXTURE_PATH); + mock = new MockAdapter(axios); + mock.onGet(`${API_ENDPOINT}?per_page=100`).reply(200, mockData, { 'x-total': 2 }); + + wrapper = mount(RelatedMergeRequests, { + localVue, + sync: false, + store: createStore(), + propsData: { + endpoint: API_ENDPOINT, + projectNamespace: 'gitlab-org', + projectPath: 'gitlab-ce', + }, + }); + + setTimeout(done); + }); + + afterEach(() => { + wrapper.destroy(); + mock.restore(); + }); + + describe('methods', () => { + describe('getAssignees', () => { + const assignees = [{ name: 'foo' }, { name: 'bar' }]; + + describe('when there is assignees array', () => { + it('should return assignees array', () => { + const mr = { assignees }; + + expect(wrapper.vm.getAssignees(mr)).toEqual(assignees); + }); + }); + + it('should return an array with single assingee', () => { + const mr = { assignee: assignees[0] }; + + expect(wrapper.vm.getAssignees(mr)).toEqual([assignees[0]]); + }); + + it('should return empty array when assignee is not set', () => { + expect(wrapper.vm.getAssignees({})).toEqual([]); + expect(wrapper.vm.getAssignees({ assignee: null })).toEqual([]); + }); + }); + }); + + describe('template', () => { + it('should render related merge request items', () => { + expect(wrapper.find('.js-items-count').text()).toEqual('2'); + expect(wrapper.findAll(RelatedIssuableItem).length).toEqual(2); + + const props = wrapper + .findAll(RelatedIssuableItem) + .at(1) + .props(); + const data = mockData[1]; + + expect(props.idKey).toEqual(data.id); + expect(props.pathIdSeparator).toEqual('!'); + expect(props.pipelineStatus).toBe(data.head_pipeline.detailed_status); + expect(props.assignees).toEqual([data.assignee]); + expect(props.isMergeRequest).toBe(true); + expect(props.confidential).toEqual(false); + expect(props.title).toEqual(data.title); + expect(props.state).toEqual(data.state); + expect(props.createdAt).toEqual(data.created_at); + }); + }); +}); diff --git a/spec/javascripts/related_merge_requests/store/actions_spec.js b/spec/javascripts/related_merge_requests/store/actions_spec.js new file mode 100644 index 00000000000..65e436fbb17 --- /dev/null +++ b/spec/javascripts/related_merge_requests/store/actions_spec.js @@ -0,0 +1,110 @@ +import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; +import * as types from '~/related_merge_requests/store/mutation_types'; +import actionsModule, * as actions from '~/related_merge_requests/store/actions'; +import testAction from 'spec/helpers/vuex_action_helper'; + +describe('RelatedMergeRequest store actions', () => { + let state; + let flashSpy; + let mock; + + beforeEach(() => { + state = { + apiEndpoint: '/api/related_merge_requests', + }; + flashSpy = spyOnDependency(actionsModule, 'createFlash'); + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + describe('setInitialState', () => { + it('commits types.SET_INITIAL_STATE with given props', done => { + const props = { a: 1, b: 2 }; + + testAction( + actions.setInitialState, + props, + {}, + [{ type: types.SET_INITIAL_STATE, payload: props }], + [], + done, + ); + }); + }); + + describe('requestData', () => { + it('commits types.REQUEST_DATA', done => { + testAction(actions.requestData, null, {}, [{ type: types.REQUEST_DATA }], [], done); + }); + }); + + describe('receiveDataSuccess', () => { + it('commits types.RECEIVE_DATA_SUCCESS with data', done => { + const data = { a: 1, b: 2 }; + + testAction( + actions.receiveDataSuccess, + data, + {}, + [{ type: types.RECEIVE_DATA_SUCCESS, payload: data }], + [], + done, + ); + }); + }); + + describe('receiveDataError', () => { + it('commits types.RECEIVE_DATA_ERROR', done => { + testAction( + actions.receiveDataError, + null, + {}, + [{ type: types.RECEIVE_DATA_ERROR }], + [], + done, + ); + }); + }); + + describe('fetchMergeRequests', () => { + describe('for a successful request', () => { + it('should dispatch success action', done => { + const data = { a: 1 }; + mock.onGet(`${state.apiEndpoint}?per_page=100`).replyOnce(200, data, { 'x-total': 2 }); + + testAction( + actions.fetchMergeRequests, + null, + state, + [], + [{ type: 'requestData' }, { type: 'receiveDataSuccess', payload: { data, total: 2 } }], + done, + ); + }); + }); + + describe('for a failing request', () => { + it('should dispatch error action', done => { + mock.onGet(`${state.apiEndpoint}?per_page=100`).replyOnce(400); + + testAction( + actions.fetchMergeRequests, + null, + state, + [], + [{ type: 'requestData' }, { type: 'receiveDataError' }], + () => { + expect(flashSpy).toHaveBeenCalledTimes(1); + expect(flashSpy).toHaveBeenCalledWith(jasmine.stringMatching('Something went wrong')); + + done(); + }, + ); + }); + }); + }); +}); diff --git a/spec/javascripts/related_merge_requests/store/mutations_spec.js b/spec/javascripts/related_merge_requests/store/mutations_spec.js new file mode 100644 index 00000000000..21b6e26376b --- /dev/null +++ b/spec/javascripts/related_merge_requests/store/mutations_spec.js @@ -0,0 +1,49 @@ +import mutations from '~/related_merge_requests/store/mutations'; +import * as types from '~/related_merge_requests/store/mutation_types'; + +describe('RelatedMergeRequests Store Mutations', () => { + describe('SET_INITIAL_STATE', () => { + it('should set initial state according to given data', () => { + const apiEndpoint = '/api'; + const state = {}; + + mutations[types.SET_INITIAL_STATE](state, { apiEndpoint }); + + expect(state.apiEndpoint).toEqual(apiEndpoint); + }); + }); + + describe('REQUEST_DATA', () => { + it('should set loading flag', () => { + const state = {}; + + mutations[types.REQUEST_DATA](state); + + expect(state.isFetchingMergeRequests).toEqual(true); + }); + }); + + describe('RECEIVE_DATA_SUCCESS', () => { + it('should set loading flag and data', () => { + const state = {}; + const mrs = [1, 2, 3]; + + mutations[types.RECEIVE_DATA_SUCCESS](state, { data: mrs, total: mrs.length }); + + expect(state.isFetchingMergeRequests).toEqual(false); + expect(state.mergeRequests).toEqual(mrs); + expect(state.totalCount).toEqual(mrs.length); + }); + }); + + describe('RECEIVE_DATA_ERROR', () => { + it('should set loading and error flags', () => { + const state = {}; + + mutations[types.RECEIVE_DATA_ERROR](state); + + expect(state.isFetchingMergeRequests).toEqual(false); + expect(state.hasErrorFetchingMergeRequests).toEqual(true); + }); + }); +}); |