...@@ -15,7 +15,6 @@ export default class Issue { ...@@ -15,7 +15,6 @@ export default class Issue {
Issue.$btnNewBranch = $('#new-branch'); Issue.$btnNewBranch = $('#new-branch');
Issue.createMrDropdownWrap = document.querySelector('.create-mr-dropdown-wrap'); Issue.createMrDropdownWrap = document.querySelector('.create-mr-dropdown-wrap');
Issue.initMergeRequests();
if (document.querySelector('#related-branches')) { if (document.querySelector('#related-branches')) {
Issue.initRelatedBranches(); Issue.initRelatedBranches();
} }
...@@ -143,19 +142,6 @@ export default class Issue { ...@@ -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() { static initRelatedBranches() {
var $container; var $container;
$container = $('#related-branches'); $container = $('#related-branches');
... ...
......
import Vue from 'vue'; import Vue from 'vue';
import sanitize from 'sanitize-html';
import issuableApp from './components/app.vue'; import issuableApp from './components/app.vue';
import { parseIssuableData } from './utils/parse_data';
import '../vue_shared/vue_resource_interceptor'; import '../vue_shared/vue_resource_interceptor';
export default function initIssueableApp() { 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({ return new Vue({
el: document.getElementById('js-issuable-app'), el: document.getElementById('js-issuable-app'),
components: { components: {
...@@ -14,7 +11,7 @@ export default function initIssueableApp() { ...@@ -14,7 +11,7 @@ export default function initIssueableApp() {
}, },
render(createElement) { render(createElement) {
return createElement('issuable-app', { return createElement('issuable-app', {
props, props: parseIssuableData(),
}); });
}, },
}); });
... ...
......
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 {};
...@@ -4,9 +4,11 @@ import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable'; ...@@ -4,9 +4,11 @@ import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable';
import ZenMode from '~/zen_mode'; import ZenMode from '~/zen_mode';
import '~/notes/index'; import '~/notes/index';
import initIssueableApp from '~/issue_show'; import initIssueableApp from '~/issue_show';
import initRelatedMergeRequestsApp from '~/related_merge_requests';
export default function() { export default function() {
initIssueableApp(); initIssueableApp();
initRelatedMergeRequestsApp();
new Issue(); // eslint-disable-line no-new new Issue(); // eslint-disable-line no-new
new ShortcutsIssuable(); // eslint-disable-line no-new new ShortcutsIssuable(); // eslint-disable-line no-new
new ZenMode(); // eslint-disable-line no-new new ZenMode(); // eslint-disable-line no-new
... ...
......
<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>
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 },
}),
});
}
}
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 () => {};
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,
});
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';
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;
},
};
export default () => ({
apiEndpoint: '',
isFetchingMergeRequests: false,
hasErrorFetchingMergeRequests: false,
mergeRequests: [],
totalCount: 0,
});
<script> <script>
import { GlTooltipDirective } from '@gitlab/ui'; import { GlTooltipDirective } from '@gitlab/ui';
import { __, sprintf } from '~/locale'; import { sprintf } from '~/locale';
import IssueMilestone from '~/vue_shared/components/issue/issue_milestone.vue'; import IssueMilestone from '../../components/issue/issue_milestone.vue';
import IssueAssignees from '~/vue_shared/components/issue/issue_assignees.vue'; import IssueAssignees from '../../components/issue/issue_assignees.vue';
import relatedIssuableMixin from '~/vue_shared/mixins/related_issuable_mixin'; import relatedIssuableMixin from '../../mixins/related_issuable_mixin';
import CiIcon from '../ci_icon.vue';
export default { export default {
name: 'IssueItem', name: 'IssueItem',
components: { components: {
IssueMilestone, IssueMilestone,
IssueAssignees, IssueAssignees,
CiIcon,
}, },
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
...@@ -27,9 +29,9 @@ export default { ...@@ -27,9 +29,9 @@ export default {
return sprintf( return sprintf(
'<span class="bold">%{state}</span> %{timeInWords}<br/><span class="text-tertiary">%{timestamp}</span>', '<span class="bold">%{state}</span> %{timeInWords}<br/><span class="text-tertiary">%{timestamp}</span>',
{ {
state: this.isOpen ? __('Opened') : __('Closed'), state: this.stateText,
timeInWords: this.isOpen ? this.createdAtInWords : this.closedAtInWords, timeInWords: this.stateTimeInWords,
timestamp: this.isOpen ? this.createdAtTimestamp : this.closedAtTimestamp, timestamp: this.stateTimestamp,
}, },
); );
}, },
...@@ -84,6 +86,11 @@ export default { ...@@ -84,6 +86,11 @@ export default {
{{ pathIdSeparator }}{{ itemId }} {{ pathIdSeparator }}{{ itemId }}
</div> </div>
<div class="item-meta-child d-flex align-items-center"> <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 <issue-milestone
v-if="hasMilestone" v-if="hasMilestone"
:milestone="milestone" :milestone="milestone"
... ...
......
import _ from 'underscore'; import _ from 'underscore';
import { sprintf, __ } from '~/locale';
import { formatDate } from '~/lib/utils/datetime_utility'; import { formatDate } from '~/lib/utils/datetime_utility';
import tooltip from '~/vue_shared/directives/tooltip'; import tooltip from '~/vue_shared/directives/tooltip';
import icon from '~/vue_shared/components/icon.vue'; import icon from '~/vue_shared/components/icon.vue';
...@@ -58,6 +59,11 @@ const mixins = { ...@@ -58,6 +59,11 @@ const mixins = {
required: false, required: false,
default: '', default: '',
}, },
mergedAt: {
type: String,
required: false,
default: '',
},
milestone: { milestone: {
type: Object, type: Object,
required: false, required: false,
...@@ -83,6 +89,16 @@ const mixins = { ...@@ -83,6 +89,16 @@ const mixins = {
required: false, required: false,
default: false, default: false,
}, },
isMergeRequest: {
type: Boolean,
required: false,
default: false,
},
pipelineStatus: {
type: Object,
required: false,
default: () => ({}),
},
}, },
components: { components: {
icon, icon,
...@@ -95,12 +111,18 @@ const mixins = { ...@@ -95,12 +111,18 @@ const mixins = {
hasState() { hasState() {
return this.state && this.state.length > 0; return this.state && this.state.length > 0;
}, },
hasPipeline() {
return this.isMergeRequest && this.pipelineStatus && Object.keys(this.pipelineStatus).length;
},
isOpen() { isOpen() {
return this.state === 'opened'; return this.state === 'opened';
}, },
isClosed() { isClosed() {
return this.state === 'closed'; return this.state === 'closed';
}, },
isMerged() {
return this.state === 'merged';
},
hasTitle() { hasTitle() {
return this.title.length > 0; return this.title.length > 0;
}, },
...@@ -108,9 +130,17 @@ const mixins = { ...@@ -108,9 +130,17 @@ const mixins = {
return !_.isEmpty(this.milestone); return !_.isEmpty(this.milestone);
}, },
iconName() { iconName() {
if (this.isMergeRequest && this.isMerged) {
return 'merge';
}
return this.isOpen ? 'issue-open-m' : 'issue-close'; return this.isOpen ? 'issue-open-m' : 'issue-close';
}, },
iconClass() { 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'; return this.isOpen ? 'issue-token-state-icon-open' : 'issue-token-state-icon-closed';
}, },
computedLinkElementType() { computedLinkElementType() {
...@@ -131,12 +161,44 @@ const mixins = { ...@@ -131,12 +161,44 @@ const mixins = {
createdAtTimestamp() { createdAtTimestamp() {
return this.createdAt ? formatDate(new Date(this.createdAt)) : ''; 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() { closedAtInWords() {
return this.closedAt ? this.timeFormated(this.closedAt) : ''; return this.closedAt ? this.timeFormated(this.closedAt) : '';
}, },
closedAtTimestamp() { closedAtTimestamp() {
return this.closedAt ? formatDate(new Date(this.closedAt)) : ''; 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: { methods: {
onRemoveRequest() { onRemoveRequest() {
... ...
......
...@@ -277,6 +277,8 @@ module IssuablesHelper ...@@ -277,6 +277,8 @@ module IssuablesHelper
initialTaskStatus: issuable.task_status initialTaskStatus: issuable.task_status
} }
data[:hasClosingMergeRequest] = issuable.merge_requests_count != 0 if issuable.is_a?(Issue)
if parent.is_a?(Group) if parent.is_a?(Group)
data[:groupPath] = parent.path data[:groupPath] = parent.path
else else
... ...
......
.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.
- 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}
...@@ -77,12 +77,11 @@ ...@@ -77,12 +77,11 @@
= edited_time_ago_with_tooltip(@issue, placement: 'bottom', html_class: 'issue-edited-ago js-issue-edited-ago') = 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) } } #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 } }
// This element is filled in using JavaScript.
- if can?(current_user, :download_code, @project) - if can?(current_user, :download_code, @project)
#related-branches{ data: { url: related_branches_project_issue_path(@project, @issue) } } #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 .content-block.emoji-block.emoji-block-sticky
.row .row
... ...
......
---
title: Rewrite related MRs widget with Vue
merge_request: 27027
author:
type: other
...@@ -141,6 +141,9 @@ msgstr "" ...@@ -141,6 +141,9 @@ msgstr ""
msgid "%{lock_path} is locked by GitLab User %{lock_user_id}" msgid "%{lock_path} is locked by GitLab User %{lock_user_id}"
msgstr "" msgstr ""
msgid "%{mrText}, this issue will be closed automatically."
msgstr ""
msgid "%{number_commits_behind} commits behind %{default_branch}, %{number_commits_ahead} commits ahead" msgid "%{number_commits_behind} commits behind %{default_branch}, %{number_commits_ahead} commits ahead"
msgstr "" msgstr ""
...@@ -5830,6 +5833,9 @@ msgstr "" ...@@ -5830,6 +5833,9 @@ msgstr ""
msgid "Pipeline triggers" msgid "Pipeline triggers"
msgstr "" msgstr ""
msgid "Pipeline: %{status}"
msgstr ""
msgid "PipelineCharts|Failed:" msgid "PipelineCharts|Failed:"
msgstr "" msgstr ""
...@@ -7510,6 +7516,9 @@ msgstr "" ...@@ -7510,6 +7516,9 @@ msgstr ""
msgid "Something went wrong while fetching comments. Please try again." msgid "Something went wrong while fetching comments. Please try again."
msgstr "" 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." msgid "Something went wrong while fetching the environments for this merge request. Please try again."
msgstr "" msgstr ""
...@@ -9238,6 +9247,11 @@ msgstr "" ...@@ -9238,6 +9247,11 @@ msgstr ""
msgid "When enabled, users cannot use GitLab until the terms have been accepted." msgid "When enabled, users cannot use GitLab until the terms have been accepted."
msgstr "" msgstr ""
msgid "When this merge request is accepted"
msgid_plural "When these merge requests are accepted"
msgstr[0] ""
msgstr[1] ""
msgid "When:" msgid "When:"
msgstr "" msgstr ""
... ...
......
...@@ -70,7 +70,7 @@ describe "Internal references", :js do ...@@ -70,7 +70,7 @@ describe "Internal references", :js do
page.within("#merge-requests ul") do page.within("#merge-requests ul") do
expect(page).to have_content(private_project_merge_request.title) 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 end
expect(page).to have_content("mentioned in merge request #{private_project_merge_request.to_reference(public_project)}") expect(page).to have_content("mentioned in merge request #{private_project_merge_request.to_reference(public_project)}")
... ...
......