diff options
author | Fatih Acet <acetfatih@gmail.com> | 2019-04-05 02:15:56 +0200 |
---|---|---|
committer | Fatih Acet <acetfatih@gmail.com> | 2019-04-05 21:20:28 +0200 |
commit | 7650677d3d832f9d65c8d38a2485ca60b97731c4 (patch) | |
tree | 9258423d5e0cc21b6c19bafb2965ea5e48d00dd8 /app/assets | |
parent | 941e00121c30baf0bf4e348d0d2b9b28891754d7 (diff) | |
download | gitlab-ce-7650677d3d832f9d65c8d38a2485ca60b97731c4.tar.gz |
Rewrite related MRs widget with Vue_acet-related-mrs-widget-rewrite
This MR rewrites existing Related Merge Requests widget
with Vue with reusing shared Related Issues components
Diffstat (limited to 'app/assets')
13 files changed, 321 insertions, 26 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() { |