diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-12-20 16:37:47 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-12-20 16:37:47 +0300 |
commit | aee0a117a889461ce8ced6fcf73207fe017f1d99 (patch) | |
tree | 891d9ef189227a8445d83f35c1b0fc99573f4380 /app/assets/javascripts/issues | |
parent | 8d46af3258650d305f53b819eabf7ab18d22f59e (diff) |
Add latest changes from gitlab-org/gitlab@14-6-stable-eev14.6.0-rc42
Diffstat (limited to 'app/assets/javascripts/issues')
53 files changed, 3591 insertions, 0 deletions
diff --git a/app/assets/javascripts/issues/constants.js b/app/assets/javascripts/issues/constants.js new file mode 100644 index 00000000000..b7b123dfd5f --- /dev/null +++ b/app/assets/javascripts/issues/constants.js @@ -0,0 +1,25 @@ +import { __ } from '~/locale'; + +export const IssuableStatus = { + Closed: 'closed', + Open: 'opened', + Reopened: 'reopened', +}; + +export const IssuableStatusText = { + [IssuableStatus.Closed]: __('Closed'), + [IssuableStatus.Open]: __('Open'), + [IssuableStatus.Reopened]: __('Open'), +}; + +export const IssuableType = { + Issue: 'issue', + Epic: 'epic', + MergeRequest: 'merge_request', + Alert: 'alert', +}; + +export const WorkspaceType = { + project: 'project', + group: 'group', +}; diff --git a/app/assets/javascripts/issues/filtered_search_service_desk.js b/app/assets/javascripts/issues/filtered_search_service_desk.js new file mode 100644 index 00000000000..bec207aa439 --- /dev/null +++ b/app/assets/javascripts/issues/filtered_search_service_desk.js @@ -0,0 +1,31 @@ +/* eslint-disable class-methods-use-this */ +import FilteredSearchManager from 'ee_else_ce/filtered_search/filtered_search_manager'; +import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys'; + +const AUTHOR_PARAM_KEY = 'author_username'; + +export default class FilteredSearchServiceDesk extends FilteredSearchManager { + constructor(supportBotData) { + super({ + page: 'service_desk', + filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys, + useDefaultState: true, + }); + + this.supportBotData = supportBotData; + } + + canEdit(tokenName) { + return tokenName !== 'author'; + } + + modifyUrlParams(paramsArray) { + const supportBotParamPair = `${AUTHOR_PARAM_KEY}=${this.supportBotData.username}`; + const onlyValidParams = paramsArray.filter((param) => param.indexOf(AUTHOR_PARAM_KEY) === -1); + + // unshift ensures author param is always first token element + onlyValidParams.unshift(supportBotParamPair); + + return onlyValidParams; + } +} diff --git a/app/assets/javascripts/issues/form.js b/app/assets/javascripts/issues/form.js new file mode 100644 index 00000000000..33371d065f9 --- /dev/null +++ b/app/assets/javascripts/issues/form.js @@ -0,0 +1,24 @@ +/* eslint-disable no-new */ + +import $ from 'jquery'; +import IssuableForm from 'ee_else_ce/issuable/issuable_form'; +import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; +import GLForm from '~/gl_form'; +import { initTitleSuggestions, initTypePopover } from '~/issues/new'; +import LabelsSelect from '~/labels/labels_select'; +import MilestoneSelect from '~/milestones/milestone_select'; +import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors'; + +export default () => { + new ShortcutsNavigation(); + new GLForm($('.issue-form')); + new IssuableForm($('.issue-form')); + new LabelsSelect(); + new MilestoneSelect(); + new IssuableTemplateSelectors({ + warnTemplateOverride: true, + }); + + initTitleSuggestions(); + initTypePopover(); +}; diff --git a/app/assets/javascripts/issues/init_filtered_search_service_desk.js b/app/assets/javascripts/issues/init_filtered_search_service_desk.js new file mode 100644 index 00000000000..1901802c11c --- /dev/null +++ b/app/assets/javascripts/issues/init_filtered_search_service_desk.js @@ -0,0 +1,11 @@ +import FilteredSearchServiceDesk from './filtered_search_service_desk'; + +export function initFilteredSearchServiceDesk() { + if (document.querySelector('.filtered-search')) { + const supportBotData = JSON.parse( + document.querySelector('.js-service-desk-issues').dataset.supportBot, + ); + const filteredSearchManager = new FilteredSearchServiceDesk(supportBotData); + filteredSearchManager.setup(); + } +} diff --git a/app/assets/javascripts/issues/issue.js b/app/assets/javascripts/issues/issue.js new file mode 100644 index 00000000000..c471875654b --- /dev/null +++ b/app/assets/javascripts/issues/issue.js @@ -0,0 +1,113 @@ +import $ from 'jquery'; +import { joinPaths } from '~/lib/utils/url_utility'; +import CreateMergeRequestDropdown from '~/create_merge_request_dropdown'; +import createFlash from '~/flash'; +import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants'; +import axios from '~/lib/utils/axios_utils'; +import { addDelimiter } from '~/lib/utils/text_utility'; +import { __ } from '~/locale'; + +export default class Issue { + constructor() { + if ($('.js-alert-moved-from-service-desk-warning').length) { + Issue.initIssueMovedFromServiceDeskDismissHandler(); + } + + if (document.querySelector('#related-branches')) { + Issue.initRelatedBranches(); + } + + Issue.createMrDropdownWrap = document.querySelector('.create-mr-dropdown-wrap'); + + if (Issue.createMrDropdownWrap) { + this.createMergeRequestDropdown = new CreateMergeRequestDropdown(Issue.createMrDropdownWrap); + } + + // Listen to state changes in the Vue app + this.issuableVueAppChangeHandler = (event) => + this.updateTopState(event.detail.isClosed, event.detail.data); + document.addEventListener(EVENT_ISSUABLE_VUE_APP_CHANGE, this.issuableVueAppChangeHandler); + } + + dispose() { + document.removeEventListener(EVENT_ISSUABLE_VUE_APP_CHANGE, this.issuableVueAppChangeHandler); + } + + /** + * This method updates the top area of the issue. + * + * Once the issue state changes, either through a click on the top area (jquery) + * or a click on the bottom area (Vue) we need to update the top area. + * + * @param {Boolean} isClosed + * @param {Array} data + * @param {String} issueFailMessage + */ + updateTopState( + isClosed, + data, + issueFailMessage = __('Unable to update this issue at this time.'), + ) { + if ('id' in data) { + const isClosedBadge = $('div.status-box-issue-closed'); + const isOpenBadge = $('div.status-box-open'); + const projectIssuesCounter = $('.issue_counter'); + + isClosedBadge.toggleClass('hidden', !isClosed); + isOpenBadge.toggleClass('hidden', isClosed); + + $(document).trigger('issuable:change', isClosed); + + let numProjectIssues = Number( + projectIssuesCounter.first().text().trim().replace(/[^\d]/, ''), + ); + numProjectIssues = isClosed ? numProjectIssues - 1 : numProjectIssues + 1; + projectIssuesCounter.text(addDelimiter(numProjectIssues)); + + if (this.createMergeRequestDropdown) { + this.createMergeRequestDropdown.checkAbilityToCreateBranch(); + } + } else { + createFlash({ + message: issueFailMessage, + }); + } + } + + static initIssueMovedFromServiceDeskDismissHandler() { + const alertMovedFromServiceDeskWarning = $('.js-alert-moved-from-service-desk-warning'); + + const trimmedPathname = window.location.pathname.slice(1); + const alertMovedFromServiceDeskDismissedKey = joinPaths( + trimmedPathname, + 'alert-issue-moved-from-service-desk-dismissed', + ); + + if (!localStorage.getItem(alertMovedFromServiceDeskDismissedKey)) { + alertMovedFromServiceDeskWarning.show(); + } + + alertMovedFromServiceDeskWarning.on('click', '.js-close', (e) => { + e.preventDefault(); + e.stopImmediatePropagation(); + alertMovedFromServiceDeskWarning.remove(); + localStorage.setItem(alertMovedFromServiceDeskDismissedKey, true); + }); + } + + static initRelatedBranches() { + const $container = $('#related-branches'); + axios + .get($container.data('url')) + .then(({ data }) => { + if ('html' in data) { + $container.html(data.html); + } + }) + .catch(() => + createFlash({ + message: __('Failed to load related branches'), + }), + ); + } +} diff --git a/app/assets/javascripts/issues/manual_ordering.js b/app/assets/javascripts/issues/manual_ordering.js new file mode 100644 index 00000000000..9613246d6a6 --- /dev/null +++ b/app/assets/javascripts/issues/manual_ordering.js @@ -0,0 +1,61 @@ +import Sortable from 'sortablejs'; +import { + getBoardSortableDefaultOptions, + sortableStart, +} from '~/boards/mixins/sortable_default_options'; +import createFlash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import { s__ } from '~/locale'; + +const updateIssue = (url, issueList, { move_before_id, move_after_id }) => + axios + .put(`${url}/reorder`, { + move_before_id, + move_after_id, + group_full_path: issueList.dataset.groupFullPath, + }) + .catch(() => { + createFlash({ + message: s__("ManualOrdering|Couldn't save the order of the issues"), + }); + }); + +const initManualOrdering = (draggableSelector = 'li.issue') => { + const issueList = document.querySelector('.manual-ordering'); + + if (!issueList || !(gon.current_user_id > 0)) { + return; + } + + Sortable.create( + issueList, + getBoardSortableDefaultOptions({ + scroll: true, + fallbackTolerance: 1, + dataIdAttr: 'data-id', + fallbackOnBody: false, + group: { + name: 'issues', + }, + draggable: draggableSelector, + onStart: () => { + sortableStart(); + }, + onUpdate: (event) => { + const el = event.item; + + const url = el.getAttribute('url') || el.dataset.url; + + const prev = el.previousElementSibling; + const next = el.nextElementSibling; + + const beforeId = prev && parseInt(prev.dataset.id, 10); + const afterId = next && parseInt(next.dataset.id, 10); + + updateIssue(url, issueList, { move_after_id: afterId, move_before_id: beforeId }); + }, + }), + ); +}; + +export default initManualOrdering; diff --git a/app/assets/javascripts/issues/new/components/title_suggestions.vue b/app/assets/javascripts/issues/new/components/title_suggestions.vue new file mode 100644 index 00000000000..0a9cdb12519 --- /dev/null +++ b/app/assets/javascripts/issues/new/components/title_suggestions.vue @@ -0,0 +1,94 @@ +<script> +import { GlTooltipDirective, GlIcon } from '@gitlab/ui'; +import { __ } from '~/locale'; +import query from '../queries/issues.query.graphql'; +import TitleSuggestionsItem from './title_suggestions_item.vue'; + +export default { + components: { + GlIcon, + TitleSuggestionsItem, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + projectPath: { + type: String, + required: true, + }, + search: { + type: String, + required: true, + }, + }, + apollo: { + issues: { + query, + debounce: 1000, + skip() { + return this.isSearchEmpty; + }, + update: (data) => data.project.issues.edges.map(({ node }) => node), + variables() { + return { + fullPath: this.projectPath, + search: this.search, + }; + }, + }, + }, + data() { + return { + issues: [], + loading: 0, + }; + }, + computed: { + isSearchEmpty() { + return !this.search.length; + }, + showSuggestions() { + return !this.isSearchEmpty && this.issues.length && !this.loading; + }, + }, + watch: { + search() { + if (this.isSearchEmpty) { + this.issues = []; + } + }, + }, + helpText: __( + 'These existing issues have a similar title. It might be better to comment there instead of creating another similar issue.', + ), +}; +</script> + +<template> + <div v-show="showSuggestions" class="form-group row"> + <div v-once class="col-form-label col-sm-2 pt-0"> + {{ __('Similar issues') }} + <gl-icon + v-gl-tooltip.bottom + :title="$options.helpText" + :aria-label="$options.helpText" + name="question-o" + class="text-secondary gl-cursor-help" + /> + </div> + <div class="col-sm-10"> + <ul class="list-unstyled m-0"> + <li + v-for="(suggestion, index) in issues" + :key="suggestion.id" + :class="{ + 'gl-mb-3': index !== issues.length - 1, + }" + > + <title-suggestions-item :suggestion="suggestion" /> + </li> + </ul> + </div> + </div> +</template> diff --git a/app/assets/javascripts/issues/new/components/title_suggestions_item.vue b/app/assets/javascripts/issues/new/components/title_suggestions_item.vue new file mode 100644 index 00000000000..a01f4f747b9 --- /dev/null +++ b/app/assets/javascripts/issues/new/components/title_suggestions_item.vue @@ -0,0 +1,132 @@ +<script> +import { GlLink, GlTooltip, GlTooltipDirective, GlIcon } from '@gitlab/ui'; +import { uniqueId } from 'lodash'; +import { __ } from '~/locale'; +import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue'; +import timeago from '~/vue_shared/mixins/timeago'; + +export default { + components: { + GlTooltip, + GlLink, + GlIcon, + UserAvatarImage, + TimeagoTooltip, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + mixins: [timeago], + props: { + suggestion: { + type: Object, + required: true, + }, + }, + computed: { + counts() { + return [ + { + id: uniqueId(), + icon: 'thumb-up', + tooltipTitle: __('Upvotes'), + count: this.suggestion.upvotes, + }, + { + id: uniqueId(), + icon: 'comment', + tooltipTitle: __('Comments'), + count: this.suggestion.userNotesCount, + }, + ].filter(({ count }) => count); + }, + isClosed() { + return this.suggestion.state === 'closed'; + }, + stateIconClass() { + return this.isClosed ? 'gl-text-blue-500' : 'gl-text-green-500'; + }, + stateIconName() { + return this.isClosed ? 'issue-close' : 'issue-open-m'; + }, + stateTitle() { + return this.isClosed ? __('Closed') : __('Opened'); + }, + closedOrCreatedDate() { + return this.suggestion.closedAt || this.suggestion.createdAt; + }, + hasUpdated() { + return this.suggestion.updatedAt !== this.suggestion.createdAt; + }, + }, +}; +</script> + +<template> + <div class="suggestion-item"> + <div class="d-flex align-items-center"> + <gl-icon + v-if="suggestion.confidential" + v-gl-tooltip.bottom + :title="__('Confidential')" + name="eye-slash" + class="gl-cursor-help gl-mr-2 gl-text-orange-500" + /> + <gl-link + :href="suggestion.webUrl" + target="_blank" + class="suggestion bold str-truncated-100 gl-text-gray-900!" + > + {{ suggestion.title }} + </gl-link> + </div> + <div class="text-secondary suggestion-footer"> + <gl-icon ref="state" :name="stateIconName" :class="stateIconClass" class="gl-cursor-help" /> + <gl-tooltip :target="() => $refs.state" placement="bottom"> + <span class="d-block"> + <span class="bold"> {{ stateTitle }} </span> {{ timeFormatted(closedOrCreatedDate) }} + </span> + <span class="text-tertiary">{{ tooltipTitle(closedOrCreatedDate) }}</span> + </gl-tooltip> + #{{ suggestion.iid }} • + <timeago-tooltip + :time="suggestion.createdAt" + tooltip-placement="bottom" + class="gl-cursor-help" + /> + {{ __('by') }} + <gl-link :href="suggestion.author.webUrl"> + <user-avatar-image + :img-src="suggestion.author.avatarUrl" + :size="16" + css-classes="mr-0 float-none" + tooltip-placement="bottom" + class="d-inline-block" + > + <span class="bold d-block">{{ __('Author') }}</span> {{ suggestion.author.name }} + <span class="text-tertiary">@{{ suggestion.author.username }}</span> + </user-avatar-image> + </gl-link> + <template v-if="hasUpdated"> + • {{ __('updated') }} + <timeago-tooltip + :time="suggestion.updatedAt" + tooltip-placement="bottom" + class="gl-cursor-help" + /> + </template> + <span class="suggestion-counts"> + <span + v-for="{ count, icon, tooltipTitle, id } in counts" + :key="id" + v-gl-tooltip.bottom + :title="tooltipTitle" + class="gl-cursor-help gl-ml-3 text-tertiary" + > + <gl-icon :name="icon" /> {{ count }} + </span> + </span> + </div> + </div> +</template> diff --git a/app/assets/javascripts/issues/new/components/type_popover.vue b/app/assets/javascripts/issues/new/components/type_popover.vue new file mode 100644 index 00000000000..a70e79b70f9 --- /dev/null +++ b/app/assets/javascripts/issues/new/components/type_popover.vue @@ -0,0 +1,41 @@ +<script> +import { GlIcon, GlPopover } from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + i18n: { + issueTypes: __('Issue types'), + issue: __('Issue'), + incident: __('Incident'), + issueHelpText: __('For general work'), + incidentHelpText: __('For investigating IT service disruptions or outages'), + }, + components: { + GlIcon, + GlPopover, + }, +}; +</script> + +<template> + <span id="popovercontainer"> + <gl-icon id="issue-type-info" name="question-o" class="gl-ml-5 gl-text-gray-500" /> + <gl-popover + target="issue-type-info" + container="popovercontainer" + :title="$options.i18n.issueTypes" + triggers="focus hover" + > + <ul class="gl-list-style-none gl-p-0 gl-m-0"> + <li class="gl-mb-3"> + <div class="gl-font-weight-bold">{{ $options.i18n.issue }}</div> + <span>{{ $options.i18n.issueHelpText }}</span> + </li> + <li> + <div class="gl-font-weight-bold">{{ $options.i18n.incident }}</div> + <span>{{ $options.i18n.incidentHelpText }}</span> + </li> + </ul> + </gl-popover> + </span> +</template> diff --git a/app/assets/javascripts/issues/new/index.js b/app/assets/javascripts/issues/new/index.js new file mode 100644 index 00000000000..59a7cbec627 --- /dev/null +++ b/app/assets/javascripts/issues/new/index.js @@ -0,0 +1,56 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import TitleSuggestions from './components/title_suggestions.vue'; +import TypePopover from './components/type_popover.vue'; + +export function initTitleSuggestions() { + Vue.use(VueApollo); + + const el = document.getElementById('js-suggestions'); + const issueTitle = document.getElementById('issue_title'); + + if (!el) { + return undefined; + } + + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), + }); + + return new Vue({ + el, + apolloProvider, + data() { + return { + search: issueTitle.value, + }; + }, + mounted() { + issueTitle.addEventListener('input', () => { + this.search = issueTitle.value; + }); + }, + render(createElement) { + return createElement(TitleSuggestions, { + props: { + projectPath: el.dataset.projectPath, + search: this.search, + }, + }); + }, + }); +} + +export function initTypePopover() { + const el = document.getElementById('js-type-popover'); + + if (!el) { + return undefined; + } + + return new Vue({ + el, + render: (createElement) => createElement(TypePopover), + }); +} diff --git a/app/assets/javascripts/issues/new/queries/issues.query.graphql b/app/assets/javascripts/issues/new/queries/issues.query.graphql new file mode 100644 index 00000000000..dc0757b141f --- /dev/null +++ b/app/assets/javascripts/issues/new/queries/issues.query.graphql @@ -0,0 +1,29 @@ +query issueSuggestion($fullPath: ID!, $search: String) { + project(fullPath: $fullPath) { + id + issues(search: $search, sort: updated_desc, first: 5) { + edges { + node { + id + iid + title + confidential + userNotesCount + upvotes + webUrl + state + closedAt + createdAt + updatedAt + author { + id + name + username + avatarUrl + webUrl + } + } + } + } + } +} diff --git a/app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue b/app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue new file mode 100644 index 00000000000..1d48446b083 --- /dev/null +++ b/app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue @@ -0,0 +1,120 @@ +<script> +import { GlLink, GlLoadingIcon, GlIcon } from '@gitlab/ui'; +import { mapState, mapActions } from 'vuex'; +import { sprintf, __, n__ } from '~/locale'; +import RelatedIssuableItem from '~/issuable/components/related_issuable_item.vue'; +import { parseIssuableData } from '~/issues/show/utils/parse_data'; + +export default { + name: 'RelatedMergeRequests', + components: { + GlIcon, + GlLink, + 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(__('%{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 class="card card-slim gl-mt-5"> + <div class="card-header"> + <div + class="card-title gl-relative gl-display-flex gl-align-items-center gl-line-height-20 gl-font-weight-bold gl-m-0" + > + <gl-link + class="anchor gl-absolute gl-text-decoration-none" + href="#related-merge-requests" + aria-labelledby="related-merge-requests" + /> + <h3 id="related-merge-requests" class="gl-font-base gl-m-0"> + {{ __('Related merge requests') }} + </h3> + <template v-if="totalCount"> + <gl-icon name="merge-request" class="gl-ml-5 gl-mr-2 gl-text-gray-500" /> + <span data-testid="count">{{ totalCount }}</span> + </template> + </div> + </div> + <gl-loading-icon + v-if="isFetchingMergeRequests" + size="sm" + label="Fetching related merge requests" + class="gl-py-3" + /> + <ul v-else class="content-list related-items-list"> + <li v-for="mr in mergeRequests" :key="mr.id" class="list-item gl-m-0! gl-p-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 + v-if="hasClosingMergeRequest && !isFetchingMergeRequests" + class="issue-closed-by-widget second-block" + > + {{ closingMergeRequestsText }} + </div> + </div> +</template> diff --git a/app/assets/javascripts/issues/related_merge_requests/index.js b/app/assets/javascripts/issues/related_merge_requests/index.js new file mode 100644 index 00000000000..ce33cf7df1d --- /dev/null +++ b/app/assets/javascripts/issues/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/issues/related_merge_requests/store/actions.js b/app/assets/javascripts/issues/related_merge_requests/store/actions.js new file mode 100644 index 00000000000..94abb50de89 --- /dev/null +++ b/app/assets/javascripts/issues/related_merge_requests/store/actions.js @@ -0,0 +1,36 @@ +import createFlash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import { normalizeHeaders } from '~/lib/utils/common_utils'; +import { __ } from '~/locale'; +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({ + message: __('Something went wrong while fetching related merge requests.'), + }); + }); +}; diff --git a/app/assets/javascripts/issues/related_merge_requests/store/index.js b/app/assets/javascripts/issues/related_merge_requests/store/index.js new file mode 100644 index 00000000000..925cc36cd76 --- /dev/null +++ b/app/assets/javascripts/issues/related_merge_requests/store/index.js @@ -0,0 +1,14 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import * as actions from './actions'; +import mutations from './mutations'; +import createState from './state'; + +Vue.use(Vuex); + +export default () => + new Vuex.Store({ + state: createState(), + actions, + mutations, + }); diff --git a/app/assets/javascripts/issues/related_merge_requests/store/mutation_types.js b/app/assets/javascripts/issues/related_merge_requests/store/mutation_types.js new file mode 100644 index 00000000000..31d4fe032e1 --- /dev/null +++ b/app/assets/javascripts/issues/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/issues/related_merge_requests/store/mutations.js b/app/assets/javascripts/issues/related_merge_requests/store/mutations.js new file mode 100644 index 00000000000..11ca28a5fb9 --- /dev/null +++ b/app/assets/javascripts/issues/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/issues/related_merge_requests/store/state.js b/app/assets/javascripts/issues/related_merge_requests/store/state.js new file mode 100644 index 00000000000..bc3468a025b --- /dev/null +++ b/app/assets/javascripts/issues/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/issues/sentry_error_stack_trace/components/sentry_error_stack_trace.vue b/app/assets/javascripts/issues/sentry_error_stack_trace/components/sentry_error_stack_trace.vue new file mode 100644 index 00000000000..1530e9a15b5 --- /dev/null +++ b/app/assets/javascripts/issues/sentry_error_stack_trace/components/sentry_error_stack_trace.vue @@ -0,0 +1,43 @@ +<script> +import { GlLoadingIcon } from '@gitlab/ui'; +import { mapActions, mapState, mapGetters } from 'vuex'; +import Stacktrace from '~/error_tracking/components/stacktrace.vue'; + +export default { + name: 'SentryErrorStackTrace', + components: { + Stacktrace, + GlLoadingIcon, + }, + props: { + issueStackTracePath: { + type: String, + required: true, + }, + }, + computed: { + ...mapState('details', ['loadingStacktrace', 'stacktraceData']), + ...mapGetters('details', ['stacktrace']), + }, + mounted() { + this.startPollingStacktrace(this.issueStackTracePath); + }, + methods: { + ...mapActions('details', ['startPollingStacktrace']), + }, +}; +</script> + +<template> + <div> + <div :class="{ 'border-bottom-0': loadingStacktrace }" class="card card-slim mt-4 mb-0"> + <div class="card-header border-bottom-0"> + <h5 class="card-title my-1">{{ __('Stack trace') }}</h5> + </div> + </div> + <div v-if="loadingStacktrace" class="card"> + <gl-loading-icon class="py-2" label="Fetching stack trace" size="sm" /> + </div> + <stacktrace v-else :entries="stacktrace" /> + </div> +</template> diff --git a/app/assets/javascripts/issues/sentry_error_stack_trace/index.js b/app/assets/javascripts/issues/sentry_error_stack_trace/index.js new file mode 100644 index 00000000000..8e9ee25e7a8 --- /dev/null +++ b/app/assets/javascripts/issues/sentry_error_stack_trace/index.js @@ -0,0 +1,22 @@ +import Vue from 'vue'; +import store from '~/error_tracking/store'; +import SentryErrorStackTrace from './components/sentry_error_stack_trace.vue'; + +export default function initSentryErrorStacktrace() { + const sentryErrorStackTraceEl = document.querySelector('#js-sentry-error-stack-trace'); + if (sentryErrorStackTraceEl) { + const { issueStackTracePath } = sentryErrorStackTraceEl.dataset; + // eslint-disable-next-line no-new + new Vue({ + el: sentryErrorStackTraceEl, + components: { + SentryErrorStackTrace, + }, + store, + render: (createElement) => + createElement('sentry-error-stack-trace', { + props: { issueStackTracePath }, + }), + }); + } +} diff --git a/app/assets/javascripts/issues/show.js b/app/assets/javascripts/issues/show.js new file mode 100644 index 00000000000..e43e56d7b4e --- /dev/null +++ b/app/assets/javascripts/issues/show.js @@ -0,0 +1,59 @@ +import loadAwardsHandler from '~/awards_handler'; +import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable'; +import { initIssuableHeaderWarnings, initIssuableSidebar } from '~/issuable'; +import { IssuableType } from '~/vue_shared/issuable/show/constants'; +import Issue from '~/issues/issue'; +import { initIncidentApp, initIncidentHeaderActions } from '~/issues/show/incident'; +import { initIssuableApp, initIssueHeaderActions } from '~/issues/show/issue'; +import { parseIssuableData } from '~/issues/show/utils/parse_data'; +import initNotesApp from '~/notes'; +import { store } from '~/notes/stores'; +import initRelatedMergeRequestsApp from '~/issues/related_merge_requests'; +import initSentryErrorStackTraceApp from '~/issues/sentry_error_stack_trace'; +import ZenMode from '~/zen_mode'; + +export default function initShowIssue() { + initNotesApp(); + + const initialDataEl = document.getElementById('js-issuable-app'); + const { issueType, ...issuableData } = parseIssuableData(initialDataEl); + + switch (issueType) { + case IssuableType.Incident: + initIncidentApp(issuableData); + initIncidentHeaderActions(store); + break; + case IssuableType.Issue: + initIssuableApp(issuableData, store); + initIssueHeaderActions(store); + break; + default: + initIssueHeaderActions(store); + break; + } + + initIssuableHeaderWarnings(store); + initSentryErrorStackTraceApp(); + initRelatedMergeRequestsApp(); + + import(/* webpackChunkName: 'design_management' */ '~/design_management') + .then((module) => module.default()) + .catch(() => {}); + + new ZenMode(); // eslint-disable-line no-new + + if (issueType !== IssuableType.TestCase) { + const awardEmojiEl = document.getElementById('js-vue-awards-block'); + + new Issue(); // eslint-disable-line no-new + new ShortcutsIssuable(); // eslint-disable-line no-new + initIssuableSidebar(); + if (awardEmojiEl) { + import('~/emoji/awards_app') + .then((m) => m.default(awardEmojiEl)) + .catch(() => {}); + } else { + loadAwardsHandler(); + } + } +} diff --git a/app/assets/javascripts/issues/show/components/app.vue b/app/assets/javascripts/issues/show/components/app.vue new file mode 100644 index 00000000000..eeaf865a35f --- /dev/null +++ b/app/assets/javascripts/issues/show/components/app.vue @@ -0,0 +1,558 @@ +<script> +import { GlIcon, GlIntersectionObserver, GlTooltipDirective } from '@gitlab/ui'; +import Visibility from 'visibilityjs'; +import createFlash from '~/flash'; +import { IssuableStatus, IssuableStatusText, IssuableType } from '~/issues/constants'; +import Poll from '~/lib/utils/poll'; +import { visitUrl } from '~/lib/utils/url_utility'; +import { __, sprintf } from '~/locale'; +import { IssueTypePath, IncidentTypePath, IncidentType, POLLING_DELAY } from '../constants'; +import eventHub from '../event_hub'; +import getIssueStateQuery from '../queries/get_issue_state.query.graphql'; +import Service from '../services/index'; +import Store from '../stores'; +import descriptionComponent from './description.vue'; +import editedComponent from './edited.vue'; +import formComponent from './form.vue'; +import PinnedLinks from './pinned_links.vue'; +import titleComponent from './title.vue'; + +export default { + components: { + GlIcon, + GlIntersectionObserver, + titleComponent, + editedComponent, + formComponent, + PinnedLinks, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + endpoint: { + required: true, + type: String, + }, + updateEndpoint: { + required: true, + type: String, + }, + canUpdate: { + required: true, + type: Boolean, + }, + canDestroy: { + required: true, + type: Boolean, + }, + showInlineEditButton: { + type: Boolean, + required: false, + default: true, + }, + showDeleteButton: { + type: Boolean, + required: false, + default: true, + }, + enableAutocomplete: { + type: Boolean, + required: false, + default: true, + }, + zoomMeetingUrl: { + type: String, + required: false, + default: '', + }, + publishedIncidentUrl: { + type: String, + required: false, + default: '', + }, + issuableRef: { + type: String, + required: true, + }, + issuableStatus: { + type: String, + required: false, + default: '', + }, + initialTitleHtml: { + type: String, + required: true, + }, + initialTitleText: { + type: String, + required: true, + }, + initialDescriptionHtml: { + type: String, + required: false, + default: '', + }, + initialDescriptionText: { + type: String, + required: false, + default: '', + }, + initialTaskStatus: { + type: String, + required: false, + default: '', + }, + updatedAt: { + type: String, + required: false, + default: '', + }, + updatedByName: { + type: String, + required: false, + default: '', + }, + updatedByPath: { + type: String, + required: false, + default: '', + }, + issuableTemplateNamesPath: { + type: String, + required: false, + default: '', + }, + markdownPreviewPath: { + type: String, + required: true, + }, + markdownDocsPath: { + type: String, + required: true, + }, + projectPath: { + type: String, + required: true, + }, + projectId: { + type: Number, + required: true, + }, + projectNamespace: { + type: String, + required: true, + }, + isConfidential: { + type: Boolean, + required: false, + default: false, + }, + isLocked: { + type: Boolean, + required: false, + default: false, + }, + issuableType: { + type: String, + required: false, + default: 'issue', + }, + canAttachFile: { + type: Boolean, + required: false, + default: true, + }, + lockVersion: { + type: Number, + required: false, + default: 0, + }, + descriptionComponent: { + type: Object, + required: false, + default: () => { + return descriptionComponent; + }, + }, + showTitleBorder: { + type: Boolean, + required: false, + default: true, + }, + isHidden: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + const store = new Store({ + titleHtml: this.initialTitleHtml, + titleText: this.initialTitleText, + descriptionHtml: this.initialDescriptionHtml, + descriptionText: this.initialDescriptionText, + updatedAt: this.updatedAt, + updatedByName: this.updatedByName, + updatedByPath: this.updatedByPath, + taskStatus: this.initialTaskStatus, + lock_version: this.lockVersion, + }); + + return { + store, + state: store.state, + showForm: false, + templatesRequested: false, + isStickyHeaderShowing: false, + issueState: {}, + }; + }, + apollo: { + issueState: { + query: getIssueStateQuery, + }, + }, + computed: { + issuableTemplates() { + return this.store.formState.issuableTemplates; + }, + formState() { + return this.store.formState; + }, + hasUpdated() { + return Boolean(this.state.updatedAt); + }, + issueChanged() { + const { + store: { + formState: { description, title }, + }, + initialDescriptionText, + initialTitleText, + } = this; + + if (initialDescriptionText || description) { + return initialDescriptionText !== description; + } + + if (initialTitleText || title) { + return initialTitleText !== title; + } + + return false; + }, + defaultErrorMessage() { + return sprintf(__('Error updating %{issuableType}'), { issuableType: this.issuableType }); + }, + isClosed() { + return this.issuableStatus === IssuableStatus.Closed; + }, + pinnedLinkClasses() { + return this.showTitleBorder + ? 'gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid gl-mb-6' + : ''; + }, + statusIcon() { + return this.isClosed ? 'issue-close' : 'issue-open-m'; + }, + statusText() { + return IssuableStatusText[this.issuableStatus]; + }, + shouldShowStickyHeader() { + return this.issuableType === IssuableType.Issue; + }, + }, + created() { + this.flashContainer = null; + this.service = new Service(this.endpoint); + this.poll = new Poll({ + resource: this.service, + method: 'getData', + successCallback: (res) => this.store.updateState(res.data), + errorCallback(err) { + throw new Error(err); + }, + }); + + if (!Visibility.hidden()) { + this.poll.makeDelayedRequest(POLLING_DELAY); + } + + Visibility.change(() => { + if (!Visibility.hidden()) { + this.poll.restart(); + } else { + this.poll.stop(); + } + }); + + window.addEventListener('beforeunload', this.handleBeforeUnloadEvent); + + eventHub.$on('update.issuable', this.updateIssuable); + eventHub.$on('close.form', this.closeForm); + eventHub.$on('open.form', this.openForm); + }, + beforeDestroy() { + eventHub.$off('update.issuable', this.updateIssuable); + eventHub.$off('close.form', this.closeForm); + eventHub.$off('open.form', this.openForm); + window.removeEventListener('beforeunload', this.handleBeforeUnloadEvent); + }, + methods: { + handleBeforeUnloadEvent(e) { + const event = e; + if (this.showForm && this.issueChanged && !this.issueState.isDirty) { + event.returnValue = __('Are you sure you want to lose your issue information?'); + } + return undefined; + }, + + updateStoreState() { + return this.service + .getData() + .then((res) => res.data) + .then((data) => { + this.store.updateState(data); + }) + .catch(() => { + createFlash({ + message: this.defaultErrorMessage, + }); + }); + }, + + updateAndShowForm(templates = {}) { + if (!this.showForm) { + this.showForm = true; + this.store.setFormState({ + title: this.state.titleText, + description: this.state.descriptionText, + lock_version: this.state.lock_version, + lockedWarningVisible: false, + updateLoading: false, + issuableTemplates: templates, + }); + } + }, + + requestTemplatesAndShowForm() { + return this.service + .loadTemplates(this.issuableTemplateNamesPath) + .then((res) => { + this.updateAndShowForm(res.data); + }) + .catch(() => { + createFlash({ + message: this.defaultErrorMessage, + }); + this.updateAndShowForm(); + }); + }, + + openForm() { + if (!this.templatesRequested) { + this.templatesRequested = true; + this.requestTemplatesAndShowForm(); + } else { + this.updateAndShowForm(this.issuableTemplates); + } + }, + + closeForm() { + this.showForm = false; + }, + + updateIssuable() { + const { + store: { formState }, + issueState, + } = this; + const issuablePayload = issueState.isDirty + ? { ...formState, issue_type: issueState.issueType } + : formState; + this.clearFlash(); + return this.service + .updateIssuable(issuablePayload) + .then((res) => res.data) + .then((data) => { + if ( + !window.location.pathname.includes(data.web_url) && + issueState.issueType !== IncidentType + ) { + visitUrl(data.web_url); + } + + if (issueState.isDirty) { + const URI = + issueState.issueType === IncidentType + ? data.web_url.replace(IssueTypePath, IncidentTypePath) + : data.web_url; + visitUrl(URI); + } + }) + .then(this.updateStoreState) + .then(() => { + eventHub.$emit('close.form'); + }) + .catch((error = {}) => { + const { message, response = {} } = error; + + this.store.setFormState({ + updateLoading: false, + }); + + let errMsg = this.defaultErrorMessage; + + if (response.data && response.data.errors) { + errMsg += `. ${response.data.errors.join(' ')}`; + } else if (message) { + errMsg += `. ${message}`; + } + + this.flashContainer = createFlash({ + message: errMsg, + }); + }); + }, + + hideStickyHeader() { + this.isStickyHeaderShowing = false; + }, + + showStickyHeader() { + this.isStickyHeaderShowing = true; + }, + + clearFlash() { + if (this.flashContainer) { + this.flashContainer.style.display = 'none'; + this.flashContainer = null; + } + }, + + taskListUpdateStarted() { + this.poll.stop(); + }, + + taskListUpdateSucceeded() { + this.poll.enable(); + this.poll.makeDelayedRequest(POLLING_DELAY); + }, + + taskListUpdateFailed() { + this.poll.enable(); + this.poll.makeDelayedRequest(POLLING_DELAY); + + this.updateStoreState(); + }, + }, +}; +</script> + +<template> + <div> + <div v-if="canUpdate && showForm"> + <form-component + :endpoint="endpoint" + :form-state="formState" + :initial-description-text="initialDescriptionText" + :can-destroy="canDestroy" + :issuable-templates="issuableTemplates" + :markdown-docs-path="markdownDocsPath" + :markdown-preview-path="markdownPreviewPath" + :project-path="projectPath" + :project-id="projectId" + :project-namespace="projectNamespace" + :show-delete-button="showDeleteButton" + :can-attach-file="canAttachFile" + :enable-autocomplete="enableAutocomplete" + :issuable-type="issuableType" + /> + </div> + <div v-else> + <title-component + :issuable-ref="issuableRef" + :can-update="canUpdate" + :title-html="state.titleHtml" + :title-text="state.titleText" + :show-inline-edit-button="showInlineEditButton" + /> + + <gl-intersection-observer + v-if="shouldShowStickyHeader" + @appear="hideStickyHeader" + @disappear="showStickyHeader" + > + <transition name="issuable-header-slide"> + <div + v-if="isStickyHeaderShowing" + class="issue-sticky-header gl-fixed gl-z-index-3 gl-bg-white gl-border-1 gl-border-b-solid gl-border-b-gray-100 gl-py-3" + data-testid="issue-sticky-header" + > + <div + class="issue-sticky-header-text gl-display-flex gl-align-items-center gl-mx-auto gl-px-5" + > + <p + class="issuable-status-box status-box gl-my-0" + :class="[isClosed ? 'status-box-issue-closed' : 'status-box-open']" + > + <gl-icon :name="statusIcon" class="gl-display-block d-sm-none gl-h-6!" /> + <span class="gl-display-none d-sm-block">{{ statusText }}</span> + </p> + <span v-if="isLocked" data-testid="locked" class="issuable-warning-icon"> + <gl-icon name="lock" :aria-label="__('Locked')" /> + </span> + <span v-if="isConfidential" data-testid="confidential" class="issuable-warning-icon"> + <gl-icon name="eye-slash" :aria-label="__('Confidential')" /> + </span> + <span + v-if="isHidden" + v-gl-tooltip + :title="__('This issue is hidden because its author has been banned')" + data-testid="hidden" + class="issuable-warning-icon" + > + <gl-icon name="spam" /> + </span> + <p + class="gl-font-weight-bold gl-overflow-hidden gl-white-space-nowrap gl-text-overflow-ellipsis gl-my-0" + :title="state.titleText" + > + {{ state.titleText }} + </p> + </div> + </div> + </transition> + </gl-intersection-observer> + + <pinned-links + :zoom-meeting-url="zoomMeetingUrl" + :published-incident-url="publishedIncidentUrl" + :class="pinnedLinkClasses" + /> + + <component + :is="descriptionComponent" + :can-update="canUpdate" + :description-html="state.descriptionHtml" + :description-text="state.descriptionText" + :updated-at="state.updatedAt" + :task-status="state.taskStatus" + :issuable-type="issuableType" + :update-url="updateEndpoint" + :lock-version="state.lock_version" + @taskListUpdateStarted="taskListUpdateStarted" + @taskListUpdateSucceeded="taskListUpdateSucceeded" + @taskListUpdateFailed="taskListUpdateFailed" + /> + + <edited-component + v-if="hasUpdated" + :updated-at="state.updatedAt" + :updated-by-name="state.updatedByName" + :updated-by-path="state.updatedByPath" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/issues/show/components/delete_issue_modal.vue b/app/assets/javascripts/issues/show/components/delete_issue_modal.vue new file mode 100644 index 00000000000..26862346b86 --- /dev/null +++ b/app/assets/javascripts/issues/show/components/delete_issue_modal.vue @@ -0,0 +1,71 @@ +<script> +import { GlModal } from '@gitlab/ui'; +import csrf from '~/lib/utils/csrf'; +import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; +import { __, sprintf } from '~/locale'; + +export default { + actionCancel: { text: __('Cancel') }, + csrf, + components: { + GlModal, + }, + props: { + issuePath: { + type: String, + required: true, + }, + issueType: { + type: String, + required: true, + }, + modalId: { + type: String, + required: true, + }, + title: { + type: String, + required: true, + }, + }, + computed: { + actionPrimary() { + return { + attributes: { variant: 'danger' }, + text: this.title, + }; + }, + bodyText() { + return this.issueType.toLowerCase() === 'epic' + ? __('Delete this epic and all descendants?') + : sprintf(__('%{issuableType} will be removed! Are you sure?'), { + issuableType: capitalizeFirstCharacter(this.issueType), + }); + }, + }, + methods: { + submitForm() { + this.$emit('delete'); + this.$refs.form.submit(); + }, + }, +}; +</script> + +<template> + <gl-modal + :action-cancel="$options.actionCancel" + :action-primary="actionPrimary" + :modal-id="modalId" + size="sm" + :title="title" + @primary="submitForm" + > + <form ref="form" :action="issuePath" method="post"> + <input type="hidden" name="_method" value="delete" /> + <input type="hidden" name="authenticity_token" :value="$options.csrf.token" /> + <input type="hidden" name="destroy_confirm" value="true" /> + {{ bodyText }} + </form> + </gl-modal> +</template> diff --git a/app/assets/javascripts/issues/show/components/description.vue b/app/assets/javascripts/issues/show/components/description.vue new file mode 100644 index 00000000000..7be4c13f544 --- /dev/null +++ b/app/assets/javascripts/issues/show/components/description.vue @@ -0,0 +1,169 @@ +<script> +import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import $ from 'jquery'; +import createFlash from '~/flash'; +import { __, sprintf } from '~/locale'; +import TaskList from '~/task_list'; +import animateMixin from '../mixins/animate'; + +export default { + directives: { + SafeHtml, + }, + + mixins: [animateMixin], + + props: { + canUpdate: { + type: Boolean, + required: true, + }, + descriptionHtml: { + type: String, + required: true, + }, + descriptionText: { + type: String, + required: false, + default: '', + }, + taskStatus: { + type: String, + required: false, + default: '', + }, + issuableType: { + type: String, + required: false, + default: 'issue', + }, + updateUrl: { + type: String, + required: false, + default: null, + }, + lockVersion: { + type: Number, + required: false, + default: 0, + }, + }, + data() { + return { + preAnimation: false, + pulseAnimation: false, + initialUpdate: true, + }; + }, + watch: { + descriptionHtml(newDescription, oldDescription) { + if (!this.initialUpdate && newDescription !== oldDescription) { + this.animateChange(); + } else { + this.initialUpdate = false; + } + + this.$nextTick(() => { + this.renderGFM(); + }); + }, + taskStatus() { + this.updateTaskStatusText(); + }, + }, + mounted() { + this.renderGFM(); + this.updateTaskStatusText(); + }, + methods: { + renderGFM() { + $(this.$refs['gfm-content']).renderGFM(); + + if (this.canUpdate) { + // eslint-disable-next-line no-new + new TaskList({ + dataType: this.issuableType, + fieldName: 'description', + lockVersion: this.lockVersion, + selector: '.detail-page-description', + onUpdate: this.taskListUpdateStarted.bind(this), + onSuccess: this.taskListUpdateSuccess.bind(this), + onError: this.taskListUpdateError.bind(this), + }); + } + }, + + taskListUpdateStarted() { + this.$emit('taskListUpdateStarted'); + }, + + taskListUpdateSuccess() { + this.$emit('taskListUpdateSucceeded'); + }, + + taskListUpdateError() { + createFlash({ + message: sprintf( + __( + 'Someone edited this %{issueType} at the same time you did. The description has been updated and you will need to make your changes again.', + ), + { + issueType: this.issuableType, + }, + ), + }); + + this.$emit('taskListUpdateFailed'); + }, + + updateTaskStatusText() { + const taskRegexMatches = this.taskStatus.match(/(\d+) of ((?!0)\d+)/); + const $issuableHeader = $('.issuable-meta'); + const $tasks = $('#task_status', $issuableHeader); + const $tasksShort = $('#task_status_short', $issuableHeader); + + if (taskRegexMatches) { + $tasks.text(this.taskStatus); + $tasksShort.text( + `${taskRegexMatches[1]}/${taskRegexMatches[2]} task${taskRegexMatches[2] > 1 ? 's' : ''}`, + ); + } else { + $tasks.text(''); + $tasksShort.text(''); + } + }, + }, + safeHtmlConfig: { ADD_TAGS: ['gl-emoji', 'copy-code'] }, +}; +</script> + +<template> + <div + v-if="descriptionHtml" + :class="{ + 'js-task-list-container': canUpdate, + }" + class="description" + > + <div + ref="gfm-content" + v-safe-html:[$options.safeHtmlConfig]="descriptionHtml" + :class="{ + 'issue-realtime-pre-pulse': preAnimation, + 'issue-realtime-trigger-pulse': pulseAnimation, + }" + class="md" + ></div> + <!-- eslint-disable vue/no-mutating-props --> + <textarea + v-if="descriptionText" + ref="textarea" + v-model="descriptionText" + :data-update-url="updateUrl" + class="hidden js-task-list-field" + dir="auto" + > + </textarea> + <!-- eslint-enable vue/no-mutating-props --> + </div> +</template> diff --git a/app/assets/javascripts/issues/show/components/edit_actions.vue b/app/assets/javascripts/issues/show/components/edit_actions.vue new file mode 100644 index 00000000000..4daf6f2b61b --- /dev/null +++ b/app/assets/javascripts/issues/show/components/edit_actions.vue @@ -0,0 +1,141 @@ +<script> +import { GlButton, GlModalDirective } from '@gitlab/ui'; +import { uniqueId } from 'lodash'; +import { __, sprintf } from '~/locale'; +import Tracking from '~/tracking'; +import eventHub from '../event_hub'; +import updateMixin from '../mixins/update'; +import getIssueStateQuery from '../queries/get_issue_state.query.graphql'; +import DeleteIssueModal from './delete_issue_modal.vue'; + +const issuableTypes = { + issue: __('Issue'), + epic: __('Epic'), + incident: __('Incident'), +}; + +const trackingMixin = Tracking.mixin({ label: 'delete_issue' }); + +export default { + components: { + DeleteIssueModal, + GlButton, + }, + directives: { + GlModal: GlModalDirective, + }, + mixins: [trackingMixin, updateMixin], + props: { + canDestroy: { + type: Boolean, + required: true, + }, + endpoint: { + required: true, + type: String, + }, + formState: { + type: Object, + required: true, + }, + showDeleteButton: { + type: Boolean, + required: false, + default: true, + }, + issuableType: { + type: String, + required: true, + }, + }, + data() { + return { + deleteLoading: false, + skipApollo: false, + issueState: {}, + modalId: uniqueId('delete-issuable-modal-'), + }; + }, + apollo: { + issueState: { + query: getIssueStateQuery, + skip() { + return this.skipApollo; + }, + result() { + this.skipApollo = true; + }, + }, + }, + computed: { + deleteIssuableButtonText() { + return sprintf(__('Delete %{issuableType}'), { + issuableType: this.typeToShow.toLowerCase(), + }); + }, + isSubmitEnabled() { + return this.formState.title.trim() !== ''; + }, + shouldShowDeleteButton() { + return this.canDestroy && this.showDeleteButton; + }, + typeToShow() { + const { issueState, issuableType } = this; + const type = issueState.issueType ?? issuableType; + return issuableTypes[type]; + }, + }, + methods: { + closeForm() { + eventHub.$emit('close.form'); + }, + deleteIssuable() { + this.deleteLoading = true; + eventHub.$emit('delete.issuable'); + }, + }, +}; +</script> + +<template> + <div class="gl-mt-3 gl-mb-3 gl-display-flex gl-justify-content-space-between"> + <div> + <gl-button + :loading="formState.updateLoading" + :disabled="formState.updateLoading || !isSubmitEnabled" + category="primary" + variant="confirm" + class="qa-save-button gl-mr-3" + data-testid="issuable-save-button" + type="submit" + @click.prevent="updateIssuable" + > + {{ __('Save changes') }} + </gl-button> + <gl-button data-testid="issuable-cancel-button" @click="closeForm"> + {{ __('Cancel') }} + </gl-button> + </div> + <div v-if="shouldShowDeleteButton"> + <gl-button + v-gl-modal="modalId" + :loading="deleteLoading" + :disabled="deleteLoading" + category="secondary" + variant="danger" + class="qa-delete-button" + data-testid="issuable-delete-button" + @click="track('click_button')" + > + {{ deleteIssuableButtonText }} + </gl-button> + <delete-issue-modal + :issue-path="endpoint" + :issue-type="typeToShow" + :modal-id="modalId" + :title="deleteIssuableButtonText" + @delete="deleteIssuable" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/issues/show/components/edited.vue b/app/assets/javascripts/issues/show/components/edited.vue new file mode 100644 index 00000000000..0da1900a6d0 --- /dev/null +++ b/app/assets/javascripts/issues/show/components/edited.vue @@ -0,0 +1,45 @@ +<script> +/* eslint-disable @gitlab/vue-require-i18n-strings */ +import timeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; + +export default { + components: { + timeAgoTooltip, + }, + props: { + updatedAt: { + type: String, + required: false, + default: '', + }, + updatedByName: { + type: String, + required: false, + default: '', + }, + updatedByPath: { + type: String, + required: false, + default: '', + }, + }, + computed: { + hasUpdatedBy() { + return this.updatedByName && this.updatedByPath; + }, + }, +}; +</script> + +<template> + <small class="edited-text"> + Edited + <time-ago-tooltip v-if="updatedAt" :time="updatedAt" tooltip-placement="bottom" /> + <span v-if="hasUpdatedBy"> + by + <a :href="updatedByPath" class="author-link"> + <span>{{ updatedByName }}</span> + </a> + </span> + </small> +</template> diff --git a/app/assets/javascripts/issues/show/components/fields/description.vue b/app/assets/javascripts/issues/show/components/fields/description.vue new file mode 100644 index 00000000000..5476a1ef897 --- /dev/null +++ b/app/assets/javascripts/issues/show/components/fields/description.vue @@ -0,0 +1,70 @@ +<script> +import markdownField from '~/vue_shared/components/markdown/field.vue'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import updateMixin from '../../mixins/update'; + +export default { + components: { + markdownField, + }, + mixins: [glFeatureFlagsMixin(), updateMixin], + props: { + formState: { + type: Object, + required: true, + }, + markdownPreviewPath: { + type: String, + required: true, + }, + markdownDocsPath: { + type: String, + required: true, + }, + canAttachFile: { + type: Boolean, + required: false, + default: true, + }, + enableAutocomplete: { + type: Boolean, + required: false, + default: true, + }, + }, + mounted() { + this.$refs.textarea.focus(); + }, +}; +</script> + +<template> + <div class="common-note-form"> + <label class="sr-only" for="issue-description">{{ __('Description') }}</label> + <markdown-field + :markdown-preview-path="markdownPreviewPath" + :markdown-docs-path="markdownDocsPath" + :can-attach-file="canAttachFile" + :enable-autocomplete="enableAutocomplete" + :textarea-value="formState.description" + > + <template #textarea> + <!-- eslint-disable vue/no-mutating-props --> + <textarea + id="issue-description" + ref="textarea" + v-model="formState.description" + class="note-textarea js-gfm-input js-autosize markdown-area qa-description-textarea" + dir="auto" + :data-supports-quick-actions="!glFeatures.tributeAutocomplete" + :aria-label="__('Description')" + :placeholder="__('Write a comment or drag your files here…')" + @keydown.meta.enter="updateIssuable" + @keydown.ctrl.enter="updateIssuable" + > + </textarea> + <!-- eslint-enable vue/no-mutating-props --> + </template> + </markdown-field> + </div> +</template> diff --git a/app/assets/javascripts/issues/show/components/fields/description_template.vue b/app/assets/javascripts/issues/show/components/fields/description_template.vue new file mode 100644 index 00000000000..9ce49b65a1a --- /dev/null +++ b/app/assets/javascripts/issues/show/components/fields/description_template.vue @@ -0,0 +1,111 @@ +<script> +import { GlIcon } from '@gitlab/ui'; +import $ from 'jquery'; +import IssuableTemplateSelectors from '~/issuable/issuable_template_selectors'; + +export default { + components: { + GlIcon, + }, + props: { + formState: { + type: Object, + required: true, + }, + issuableTemplates: { + type: [Object, Array], + required: false, + default: () => ({}), + }, + projectPath: { + type: String, + required: true, + }, + projectId: { + type: Number, + required: true, + }, + projectNamespace: { + type: String, + required: true, + }, + }, + computed: { + issuableTemplatesJson() { + return JSON.stringify(this.issuableTemplates); + }, + }, + mounted() { + // Create the editor for the template + const editor = document.querySelector('.detail-page-description .note-textarea') || {}; + editor.setValue = (val) => { + // eslint-disable-next-line vue/no-mutating-props + this.formState.description = val; + }; + editor.getValue = () => this.formState.description; + + this.issuableTemplate = new IssuableTemplateSelectors({ + $dropdowns: $(this.$refs.toggle), + editor, + }); + }, +}; +</script> + +<template> + <!-- eslint-disable @gitlab/vue-no-data-toggle --> + <div class="dropdown js-issuable-selector-wrap gl-mb-0" data-issuable-type="issues"> + <button + ref="toggle" + :data-namespace-path="projectNamespace" + :data-project-path="projectPath" + :data-project-id="projectId" + :data-data="issuableTemplatesJson" + class="dropdown-menu-toggle js-issuable-selector gl-button" + type="button" + data-field-name="issuable_template" + data-selected="null" + data-toggle="dropdown" + > + <span class="dropdown-toggle-text">{{ __('Choose a template') }}</span> + <gl-icon name="chevron-down" class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-500" /> + </button> + <div class="dropdown-menu dropdown-select"> + <div class="dropdown-title gl-display-flex gl-justify-content-center"> + <span class="gl-ml-auto">{{ __('Choose a template') }}</span> + <button + class="dropdown-title-button dropdown-menu-close gl-ml-auto" + :aria-label="__('Close')" + type="button" + > + <gl-icon name="close" class="dropdown-menu-close-icon" /> + </button> + </div> + <div class="dropdown-input"> + <input + type="search" + class="dropdown-input-field" + :placeholder="__('Filter')" + autocomplete="off" + /> + <gl-icon name="search" class="dropdown-input-search" /> + <gl-icon + name="close" + class="dropdown-input-clear js-dropdown-input-clear" + :aria-label="__('Clear templates search input')" + /> + </div> + <div class="dropdown-content"></div> + <div class="dropdown-footer"> + <ul class="dropdown-footer-list"> + <li> + <a class="no-template">{{ __('No template') }}</a> + </li> + <li> + <a class="reset-template">{{ __('Reset template') }}</a> + </li> + </ul> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/issues/show/components/fields/title.vue b/app/assets/javascripts/issues/show/components/fields/title.vue new file mode 100644 index 00000000000..a73926575d0 --- /dev/null +++ b/app/assets/javascripts/issues/show/components/fields/title.vue @@ -0,0 +1,33 @@ +<script> +import updateMixin from '../../mixins/update'; + +export default { + mixins: [updateMixin], + props: { + formState: { + type: Object, + required: true, + }, + }, +}; +</script> + +<template> + <fieldset> + <label class="sr-only" for="issuable-title">{{ __('Title') }}</label> + <!-- eslint-disable vue/no-mutating-props --> + <input + id="issuable-title" + ref="input" + v-model="formState.title" + class="form-control qa-title-input gl-border-gray-200" + dir="auto" + type="text" + :placeholder="__('Title')" + :aria-label="__('Title')" + @keydown.meta.enter="updateIssuable" + @keydown.ctrl.enter="updateIssuable" + /> + <!-- eslint-enable vue/no-mutating-props --> + </fieldset> +</template> diff --git a/app/assets/javascripts/issues/show/components/fields/type.vue b/app/assets/javascripts/issues/show/components/fields/type.vue new file mode 100644 index 00000000000..9110a6924b4 --- /dev/null +++ b/app/assets/javascripts/issues/show/components/fields/type.vue @@ -0,0 +1,96 @@ +<script> +import { GlFormGroup, GlDropdown, GlDropdownItem, GlIcon } from '@gitlab/ui'; +import { capitalize } from 'lodash'; +import { __ } from '~/locale'; +import { IssuableTypes, IncidentType } from '../../constants'; +import getIssueStateQuery from '../../queries/get_issue_state.query.graphql'; +import updateIssueStateMutation from '../../queries/update_issue_state.mutation.graphql'; + +export const i18n = { + label: __('Issue Type'), +}; + +export default { + i18n, + IssuableTypes, + components: { + GlFormGroup, + GlIcon, + GlDropdown, + GlDropdownItem, + }, + inject: { + canCreateIncident: { + default: false, + }, + issueType: { + default: 'issue', + }, + }, + data() { + return { + issueState: {}, + }; + }, + apollo: { + issueState: { + query: getIssueStateQuery, + }, + }, + computed: { + dropdownText() { + const { + issueState: { issueType }, + } = this; + return capitalize(issueType); + }, + shouldShowIncident() { + return this.issueType === IncidentType || this.canCreateIncident; + }, + }, + methods: { + updateIssueType(issueType) { + this.$apollo.mutate({ + mutation: updateIssueStateMutation, + variables: { + issueType, + isDirty: true, + }, + }); + }, + isShown(type) { + return type.value !== IncidentType || this.shouldShowIncident; + }, + }, +}; +</script> + +<template> + <gl-form-group + :label="$options.i18n.label" + label-class="sr-only" + label-for="issuable-type" + class="mb-2 mb-md-0" + > + <gl-dropdown + id="issuable-type" + :aria-labelledby="$options.i18n.label" + :text="dropdownText" + :header-text="$options.i18n.label" + class="gl-w-full" + toggle-class="dropdown-menu-toggle" + > + <gl-dropdown-item + v-for="type in $options.IssuableTypes" + v-show="isShown(type)" + :key="type.value" + :is-checked="issueState.issueType === type.value" + is-check-item + @click="updateIssueType(type.value)" + > + <gl-icon :name="type.icon" /> + {{ type.text }} + </gl-dropdown-item> + </gl-dropdown> + </gl-form-group> +</template> diff --git a/app/assets/javascripts/issues/show/components/form.vue b/app/assets/javascripts/issues/show/components/form.vue new file mode 100644 index 00000000000..6447ec85b4e --- /dev/null +++ b/app/assets/javascripts/issues/show/components/form.vue @@ -0,0 +1,227 @@ +<script> +import { GlAlert } from '@gitlab/ui'; +import $ from 'jquery'; +import Autosave from '~/autosave'; +import { IssuableType } from '~/issues/constants'; +import eventHub from '../event_hub'; +import EditActions from './edit_actions.vue'; +import DescriptionField from './fields/description.vue'; +import DescriptionTemplateField from './fields/description_template.vue'; +import IssuableTitleField from './fields/title.vue'; +import IssuableTypeField from './fields/type.vue'; +import LockedWarning from './locked_warning.vue'; + +export default { + components: { + DescriptionField, + DescriptionTemplateField, + EditActions, + GlAlert, + IssuableTitleField, + IssuableTypeField, + LockedWarning, + }, + props: { + canDestroy: { + type: Boolean, + required: true, + }, + endpoint: { + type: String, + required: true, + }, + formState: { + type: Object, + required: true, + }, + issuableTemplates: { + type: [Object, Array], + required: false, + default: () => [], + }, + issuableType: { + type: String, + required: true, + }, + markdownPreviewPath: { + type: String, + required: true, + }, + markdownDocsPath: { + type: String, + required: true, + }, + projectPath: { + type: String, + required: true, + }, + projectId: { + type: Number, + required: true, + }, + projectNamespace: { + type: String, + required: true, + }, + showDeleteButton: { + type: Boolean, + required: false, + default: true, + }, + canAttachFile: { + type: Boolean, + required: false, + default: true, + }, + enableAutocomplete: { + type: Boolean, + required: false, + default: true, + }, + initialDescriptionText: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + showOutdatedDescriptionWarning: false, + }; + }, + computed: { + hasIssuableTemplates() { + return Object.values(Object(this.issuableTemplates)).length; + }, + showLockedWarning() { + return this.formState.lockedWarningVisible && !this.formState.updateLoading; + }, + isIssueType() { + return this.issuableType === IssuableType.Issue; + }, + }, + created() { + eventHub.$on('delete.issuable', this.resetAutosave); + eventHub.$on('update.issuable', this.resetAutosave); + eventHub.$on('close.form', this.resetAutosave); + }, + mounted() { + this.initAutosave(); + }, + beforeDestroy() { + eventHub.$off('delete.issuable', this.resetAutosave); + eventHub.$off('update.issuable', this.resetAutosave); + eventHub.$off('close.form', this.resetAutosave); + }, + methods: { + initAutosave() { + const { + description: { + $refs: { textarea }, + }, + title: { + $refs: { input }, + }, + } = this.$refs; + + this.autosaveDescription = new Autosave( + $(textarea), + [document.location.pathname, document.location.search, 'description'], + null, + this.formState.lock_version, + ); + + const savedLockVersion = this.autosaveDescription.getSavedLockVersion(); + + this.showOutdatedDescriptionWarning = + savedLockVersion && String(this.formState.lock_version) !== savedLockVersion; + + this.autosaveTitle = new Autosave($(input), [ + document.location.pathname, + document.location.search, + 'title', + ]); + }, + resetAutosave() { + this.autosaveDescription.reset(); + this.autosaveTitle.reset(); + }, + keepAutosave() { + const { + description: { + $refs: { textarea }, + }, + } = this.$refs; + + textarea.focus(); + this.showOutdatedDescriptionWarning = false; + }, + discardAutosave() { + const { + description: { + $refs: { textarea }, + }, + } = this.$refs; + + textarea.value = this.initialDescriptionText; + textarea.focus(); + this.showOutdatedDescriptionWarning = false; + }, + }, +}; +</script> + +<template> + <form data-testid="issuable-form"> + <locked-warning v-if="showLockedWarning" /> + <gl-alert + v-if="showOutdatedDescriptionWarning" + class="gl-mb-5" + variant="warning" + :primary-button-text="__('Keep')" + :secondary-button-text="__('Discard')" + :dismissible="false" + @primaryAction="keepAutosave" + @secondaryAction="discardAutosave" + >{{ + __( + 'The comment you are editing has been changed by another user. Would you like to keep your changes and overwrite the new description or discard your changes?', + ) + }}</gl-alert + > + <div class="row gl-mb-3"> + <div class="col-12"> + <issuable-title-field ref="title" :form-state="formState" /> + </div> + </div> + <div class="row"> + <div v-if="isIssueType" class="col-12 col-md-4 pr-md-0"> + <issuable-type-field ref="issue-type" /> + </div> + <div v-if="hasIssuableTemplates" class="col-12 col-md-4 pl-md-2"> + <description-template-field + :form-state="formState" + :issuable-templates="issuableTemplates" + :project-path="projectPath" + :project-id="projectId" + :project-namespace="projectNamespace" + /> + </div> + </div> + <description-field + ref="description" + :form-state="formState" + :markdown-preview-path="markdownPreviewPath" + :markdown-docs-path="markdownDocsPath" + :can-attach-file="canAttachFile" + :enable-autocomplete="enableAutocomplete" + /> + <edit-actions + :endpoint="endpoint" + :form-state="formState" + :can-destroy="canDestroy" + :show-delete-button="showDeleteButton" + :issuable-type="issuableType" + /> + </form> +</template> diff --git a/app/assets/javascripts/issues/show/components/header_actions.vue b/app/assets/javascripts/issues/show/components/header_actions.vue new file mode 100644 index 00000000000..700ef92a0f3 --- /dev/null +++ b/app/assets/javascripts/issues/show/components/header_actions.vue @@ -0,0 +1,345 @@ +<script> +import { + GlButton, + GlDropdown, + GlDropdownDivider, + GlDropdownItem, + GlLink, + GlModal, + GlModalDirective, +} from '@gitlab/ui'; +import { mapActions, mapGetters, mapState } from 'vuex'; +import createFlash, { FLASH_TYPES } from '~/flash'; +import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants'; +import { IssuableType } from '~/vue_shared/issuable/show/constants'; +import { IssuableStatus } from '~/issues/constants'; +import { IssueStateEvent } from '~/issues/show/constants'; +import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; +import { visitUrl } from '~/lib/utils/url_utility'; +import { s__, __, sprintf } from '~/locale'; +import eventHub from '~/notes/event_hub'; +import Tracking from '~/tracking'; +import promoteToEpicMutation from '../queries/promote_to_epic.mutation.graphql'; +import updateIssueMutation from '../queries/update_issue.mutation.graphql'; +import DeleteIssueModal from './delete_issue_modal.vue'; + +const trackingMixin = Tracking.mixin({ label: 'delete_issue' }); + +export default { + actionCancel: { + text: __('Cancel'), + }, + actionPrimary: { + text: __('Yes, close issue'), + }, + deleteModalId: 'delete-modal-id', + i18n: { + promoteErrorMessage: __( + 'Something went wrong while promoting the issue to an epic. Please try again.', + ), + promoteSuccessMessage: __( + 'The issue was successfully promoted to an epic. Redirecting to epic...', + ), + }, + components: { + DeleteIssueModal, + GlButton, + GlDropdown, + GlDropdownDivider, + GlDropdownItem, + GlLink, + GlModal, + }, + directives: { + GlModal: GlModalDirective, + }, + mixins: [trackingMixin], + inject: { + canCreateIssue: { + default: false, + }, + canDestroyIssue: { + default: false, + }, + canPromoteToEpic: { + default: false, + }, + canReopenIssue: { + default: false, + }, + canReportSpam: { + default: false, + }, + canUpdateIssue: { + default: false, + }, + iid: { + default: '', + }, + isIssueAuthor: { + default: false, + }, + issuePath: { + default: '', + }, + issueType: { + default: IssuableType.Issue, + }, + newIssuePath: { + default: '', + }, + projectPath: { + default: '', + }, + reportAbusePath: { + default: '', + }, + submitAsSpamPath: { + default: '', + }, + }, + computed: { + ...mapState(['isToggleStateButtonLoading']), + ...mapGetters(['openState', 'getBlockedByIssues']), + isClosed() { + return this.openState === IssuableStatus.Closed; + }, + issueTypeText() { + const issueTypeTexts = { + [IssuableType.Issue]: s__('HeaderAction|issue'), + [IssuableType.Incident]: s__('HeaderAction|incident'), + }; + + return issueTypeTexts[this.issueType] ?? this.issueType; + }, + buttonText() { + return this.isClosed + ? sprintf(__('Reopen %{issueType}'), { issueType: this.issueTypeText }) + : sprintf(__('Close %{issueType}'), { issueType: this.issueTypeText }); + }, + deleteButtonText() { + return sprintf(__('Delete %{issuableType}'), { issuableType: this.issueTypeText }); + }, + qaSelector() { + return this.isClosed ? 'reopen_issue_button' : 'close_issue_button'; + }, + dropdownText() { + return sprintf(__('%{issueType} actions'), { + issueType: capitalizeFirstCharacter(this.issueType), + }); + }, + newIssueTypeText() { + return sprintf(__('New %{issueType}'), { issueType: this.issueType }); + }, + showToggleIssueStateButton() { + const canClose = !this.isClosed && this.canUpdateIssue; + const canReopen = this.isClosed && this.canReopenIssue; + return canClose || canReopen; + }, + }, + created() { + eventHub.$on('toggle.issuable.state', this.toggleIssueState); + }, + beforeDestroy() { + eventHub.$off('toggle.issuable.state', this.toggleIssueState); + }, + methods: { + ...mapActions(['toggleStateButtonLoading']), + toggleIssueState() { + if (!this.isClosed && this.getBlockedByIssues?.length) { + this.$refs.blockedByIssuesModal.show(); + return; + } + + this.invokeUpdateIssueMutation(); + }, + invokeUpdateIssueMutation() { + this.toggleStateButtonLoading(true); + + this.$apollo + .mutate({ + mutation: updateIssueMutation, + variables: { + input: { + iid: this.iid.toString(), + projectPath: this.projectPath, + stateEvent: this.isClosed ? IssueStateEvent.Reopen : IssueStateEvent.Close, + }, + }, + }) + .then(({ data }) => { + if (data.updateIssue.errors.length) { + throw new Error(); + } + + const payload = { + detail: { + data: { id: this.iid }, + isClosed: !this.isClosed, + }, + }; + + // Dispatch event which updates open/close state, shared among the issue show page + document.dispatchEvent(new CustomEvent(EVENT_ISSUABLE_VUE_APP_CHANGE, payload)); + }) + .catch(() => createFlash({ message: __('Error occurred while updating the issue status') })) + .finally(() => { + this.toggleStateButtonLoading(false); + }); + }, + promoteToEpic() { + this.toggleStateButtonLoading(true); + + this.$apollo + .mutate({ + mutation: promoteToEpicMutation, + variables: { + input: { + iid: this.iid, + projectPath: this.projectPath, + }, + }, + }) + .then(({ data }) => { + if (data.promoteToEpic.errors.length) { + throw new Error(); + } + + createFlash({ + message: this.$options.i18n.promoteSuccessMessage, + type: FLASH_TYPES.SUCCESS, + }); + + visitUrl(data.promoteToEpic.epic.webPath); + }) + .catch(() => createFlash({ message: this.$options.i18n.promoteErrorMessage })) + .finally(() => { + this.toggleStateButtonLoading(false); + }); + }, + }, +}; +</script> + +<template> + <div class="detail-page-header-actions gl-display-flex"> + <gl-dropdown + class="gl-sm-display-none! w-100" + block + :text="dropdownText" + data-qa-selector="issue_actions_dropdown" + :loading="isToggleStateButtonLoading" + > + <gl-dropdown-item + v-if="showToggleIssueStateButton" + :data-qa-selector="`mobile_${qaSelector}`" + @click="toggleIssueState" + > + {{ buttonText }} + </gl-dropdown-item> + <gl-dropdown-item v-if="canCreateIssue" :href="newIssuePath"> + {{ newIssueTypeText }} + </gl-dropdown-item> + <gl-dropdown-item v-if="canPromoteToEpic" @click="promoteToEpic"> + {{ __('Promote to epic') }} + </gl-dropdown-item> + <gl-dropdown-item v-if="!isIssueAuthor" :href="reportAbusePath"> + {{ __('Report abuse') }} + </gl-dropdown-item> + <gl-dropdown-item + v-if="canReportSpam" + :href="submitAsSpamPath" + data-method="post" + rel="nofollow" + > + {{ __('Submit as spam') }} + </gl-dropdown-item> + <template v-if="canDestroyIssue"> + <gl-dropdown-divider /> + <gl-dropdown-item + v-gl-modal="$options.deleteModalId" + variant="danger" + @click="track('click_dropdown')" + > + {{ deleteButtonText }} + </gl-dropdown-item> + </template> + </gl-dropdown> + + <gl-button + v-if="showToggleIssueStateButton" + class="gl-display-none gl-sm-display-inline-flex!" + :data-qa-selector="qaSelector" + :loading="isToggleStateButtonLoading" + @click="toggleIssueState" + > + {{ buttonText }} + </gl-button> + + <gl-dropdown + class="gl-display-none gl-sm-display-inline-flex! gl-ml-3" + icon="ellipsis_v" + category="tertiary" + :text="dropdownText" + :text-sr-only="true" + no-caret + right + > + <gl-dropdown-item v-if="canCreateIssue" :href="newIssuePath"> + {{ newIssueTypeText }} + </gl-dropdown-item> + <gl-dropdown-item + v-if="canPromoteToEpic" + :disabled="isToggleStateButtonLoading" + data-testid="promote-button" + @click="promoteToEpic" + > + {{ __('Promote to epic') }} + </gl-dropdown-item> + <gl-dropdown-item v-if="!isIssueAuthor" :href="reportAbusePath"> + {{ __('Report abuse') }} + </gl-dropdown-item> + <gl-dropdown-item + v-if="canReportSpam" + :href="submitAsSpamPath" + data-method="post" + rel="nofollow" + > + {{ __('Submit as spam') }} + </gl-dropdown-item> + <template v-if="canDestroyIssue"> + <gl-dropdown-divider /> + <gl-dropdown-item + v-gl-modal="$options.deleteModalId" + variant="danger" + @click="track('click_dropdown')" + > + {{ deleteButtonText }} + </gl-dropdown-item> + </template> + </gl-dropdown> + + <gl-modal + ref="blockedByIssuesModal" + modal-id="blocked-by-issues-modal" + :action-cancel="$options.actionCancel" + :action-primary="$options.actionPrimary" + :title="__('Are you sure you want to close this blocked issue?')" + @primary="invokeUpdateIssueMutation" + > + <p>{{ __('This issue is currently blocked by the following issues:') }}</p> + <ul> + <li v-for="issue in getBlockedByIssues" :key="issue.iid"> + <gl-link :href="issue.web_url">#{{ issue.iid }}</gl-link> + </li> + </ul> + </gl-modal> + + <delete-issue-modal + :issue-path="issuePath" + :issue-type="issueType" + :modal-id="$options.deleteModalId" + :title="deleteButtonText" + /> + </div> +</template> diff --git a/app/assets/javascripts/issues/show/components/incidents/graphql/queries/get_alert.graphql b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/get_alert.graphql new file mode 100644 index 00000000000..d88633f2ae9 --- /dev/null +++ b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/get_alert.graphql @@ -0,0 +1,23 @@ +query getAlert($iid: String!, $fullPath: ID!) { + project(fullPath: $fullPath) { + id + issue(iid: $iid) { + id + alertManagementAlert { + iid + title + detailsUrl + severity + status + startedAt + eventCount + monitoringTool + service + description + endedAt + hosts + details + } + } + } +} diff --git a/app/assets/javascripts/issues/show/components/incidents/highlight_bar.vue b/app/assets/javascripts/issues/show/components/incidents/highlight_bar.vue new file mode 100644 index 00000000000..d509f0dbc09 --- /dev/null +++ b/app/assets/javascripts/issues/show/components/incidents/highlight_bar.vue @@ -0,0 +1,63 @@ +<script> +import { GlLink, GlTooltipDirective } from '@gitlab/ui'; +import { formatDate } from '~/lib/utils/datetime_utility'; + +export default { + components: { + GlLink, + IncidentSla: () => import('ee_component/issues/show/components/incidents/incident_sla.vue'), + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + alert: { + type: Object, + required: false, + default: null, + }, + }, + data() { + return { childHasData: false }; + }, + computed: { + startTime() { + return formatDate(this.alert.startedAt, 'yyyy-mm-dd Z'); + }, + showHighlightBar() { + return this.alert || this.childHasData; + }, + }, + methods: { + update(hasData) { + this.childHasData = hasData; + }, + }, +}; +</script> + +<template> + <div + v-show="showHighlightBar" + class="gl-border-solid gl-border-1 gl-border-gray-100 gl-p-5 gl-mb-3 gl-rounded-base gl-display-flex gl-justify-content-space-between gl-xs-flex-direction-column" + > + <div v-if="alert" class="gl-mr-3"> + <span class="gl-font-weight-bold">{{ s__('HighlightBar|Original alert:') }}</span> + <gl-link v-gl-tooltip :title="alert.title" :href="alert.detailsUrl"> + #{{ alert.iid }} + </gl-link> + </div> + + <div v-if="alert" class="gl-mr-3"> + <span class="gl-font-weight-bold">{{ s__('HighlightBar|Alert start time:') }}</span> + {{ startTime }} + </div> + + <div v-if="alert" class="gl-mr-3"> + <span class="gl-font-weight-bold">{{ s__('HighlightBar|Alert events:') }}</span> + <span>{{ alert.eventCount }}</span> + </div> + + <incident-sla @update="update" /> + </div> +</template> diff --git a/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue b/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue new file mode 100644 index 00000000000..4790062ab7d --- /dev/null +++ b/app/assets/javascripts/issues/show/components/incidents/incident_tabs.vue @@ -0,0 +1,81 @@ +<script> +import { GlTab, GlTabs } from '@gitlab/ui'; +import createFlash from '~/flash'; +import { trackIncidentDetailsViewsOptions } from '~/incidents/constants'; +import { s__ } from '~/locale'; +import Tracking from '~/tracking'; +import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue'; +import DescriptionComponent from '../description.vue'; +import getAlert from './graphql/queries/get_alert.graphql'; +import HighlightBar from './highlight_bar.vue'; + +export default { + components: { + AlertDetailsTable, + DescriptionComponent, + GlTab, + GlTabs, + HighlightBar, + MetricsTab: () => import('ee_component/issues/show/components/incidents/metrics_tab.vue'), + }, + inject: ['fullPath', 'iid', 'uploadMetricsFeatureAvailable'], + apollo: { + alert: { + query: getAlert, + variables() { + return { + fullPath: this.fullPath, + iid: this.iid, + }; + }, + update(data) { + return data?.project?.issue?.alertManagementAlert; + }, + error() { + createFlash({ + message: s__('Incident|There was an issue loading alert data. Please try again.'), + }); + }, + }, + }, + data() { + return { + alert: null, + }; + }, + computed: { + loading() { + return this.$apollo.queries.alert.loading; + }, + }, + mounted() { + this.trackPageViews(); + }, + methods: { + trackPageViews() { + const { category, action } = trackIncidentDetailsViewsOptions; + Tracking.event(category, action); + }, + }, +}; +</script> + +<template> + <div> + <gl-tabs content-class="gl-reset-line-height" class="gl-mt-n3" data-testid="incident-tabs"> + <gl-tab :title="s__('Incident|Summary')"> + <highlight-bar :alert="alert" /> + <description-component v-bind="$attrs" /> + </gl-tab> + <metrics-tab v-if="uploadMetricsFeatureAvailable" data-testid="metrics-tab" /> + <gl-tab + v-if="alert" + class="alert-management-details" + :title="s__('Incident|Alert details')" + data-testid="alert-details-tab" + > + <alert-details-table :alert="alert" :loading="loading" /> + </gl-tab> + </gl-tabs> + </div> +</template> diff --git a/app/assets/javascripts/issues/show/components/locked_warning.vue b/app/assets/javascripts/issues/show/components/locked_warning.vue new file mode 100644 index 00000000000..4b99888ae73 --- /dev/null +++ b/app/assets/javascripts/issues/show/components/locked_warning.vue @@ -0,0 +1,33 @@ +<script> +import { GlSprintf, GlLink } from '@gitlab/ui'; +import { __ } from '~/locale'; + +const alertMessage = __( + 'Someone edited the issue at the same time you did. Please check out %{linkStart}the issue%{linkEnd} and make sure your changes will not unintentionally remove theirs.', +); + +export default { + alertMessage, + components: { + GlSprintf, + GlLink, + }, + computed: { + currentPath() { + return window.location.pathname; + }, + }, +}; +</script> + +<template> + <div class="alert alert-danger"> + <gl-sprintf :message="$options.alertMessage"> + <template #link="{ content }"> + <gl-link :href="currentPath" target="_blank" rel="nofollow"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </div> +</template> diff --git a/app/assets/javascripts/issues/show/components/pinned_links.vue b/app/assets/javascripts/issues/show/components/pinned_links.vue new file mode 100644 index 00000000000..d38189307bd --- /dev/null +++ b/app/assets/javascripts/issues/show/components/pinned_links.vue @@ -0,0 +1,68 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import { STATUS_PAGE_PUBLISHED, JOIN_ZOOM_MEETING } from '../constants'; + +export default { + components: { + GlButton, + }, + props: { + zoomMeetingUrl: { + type: String, + required: false, + default: '', + }, + publishedIncidentUrl: { + type: String, + required: false, + default: '', + }, + }, + computed: { + pinnedLinks() { + const links = []; + if (this.publishedIncidentUrl) { + links.push({ + id: 'publishedIncidentUrl', + url: this.publishedIncidentUrl, + text: STATUS_PAGE_PUBLISHED, + icon: 'tanuki', + }); + } + if (this.zoomMeetingUrl) { + links.push({ + id: 'zoomMeetingUrl', + url: this.zoomMeetingUrl, + text: JOIN_ZOOM_MEETING, + icon: 'brand-zoom', + }); + } + + return links; + }, + }, + methods: { + needsPaddingClass(i) { + return i < this.pinnedLinks.length - 1; + }, + }, +}; +</script> + +<template> + <div v-if="pinnedLinks && pinnedLinks.length" class="gl-display-flex gl-justify-content-start"> + <template v-for="(link, i) in pinnedLinks"> + <div v-if="link.url" :key="link.id" :class="{ 'gl-pr-3': needsPaddingClass(i) }"> + <gl-button + :href="link.url" + target="_blank" + :icon="link.icon" + size="small" + class="gl-font-weight-bold gl-mb-5" + :data-testid="link.id" + >{{ link.text }}</gl-button + > + </div> + </template> + </div> +</template> diff --git a/app/assets/javascripts/issues/show/components/title.vue b/app/assets/javascripts/issues/show/components/title.vue new file mode 100644 index 00000000000..5e92211685a --- /dev/null +++ b/app/assets/javascripts/issues/show/components/title.vue @@ -0,0 +1,90 @@ +<script> +import { GlButton, GlTooltipDirective, GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import { __ } from '~/locale'; +import eventHub from '../event_hub'; +import animateMixin from '../mixins/animate'; + +export default { + i18n: { + editTitleAndDescription: __('Edit title and description'), + }, + components: { + GlButton, + }, + directives: { + GlTooltip: GlTooltipDirective, + SafeHtml, + }, + mixins: [animateMixin], + props: { + issuableRef: { + type: [String, Number], + required: true, + }, + canUpdate: { + required: false, + type: Boolean, + default: false, + }, + titleHtml: { + type: String, + required: true, + }, + titleText: { + type: String, + required: true, + }, + showInlineEditButton: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + preAnimation: false, + pulseAnimation: false, + titleEl: document.querySelector('title'), + }; + }, + watch: { + titleHtml() { + this.setPageTitle(); + this.animateChange(); + }, + }, + methods: { + setPageTitle() { + const currentPageTitleScope = this.titleEl.innerText.split('·'); + currentPageTitleScope[0] = `${this.titleText} (${this.issuableRef}) `; + this.titleEl.textContent = currentPageTitleScope.join('·'); + }, + edit() { + eventHub.$emit('open.form'); + }, + }, +}; +</script> + +<template> + <div class="title-container"> + <h2 + v-safe-html="titleHtml" + :class="{ + 'issue-realtime-pre-pulse': preAnimation, + 'issue-realtime-trigger-pulse': pulseAnimation, + }" + class="title qa-title" + dir="auto" + ></h2> + <gl-button + v-if="showInlineEditButton && canUpdate" + v-gl-tooltip.bottom + icon="pencil" + class="btn-edit js-issuable-edit qa-edit-button" + :title="$options.i18n.editTitleAndDescription" + :aria-label="$options.i18n.editTitleAndDescription" + @click="edit" + /> + </div> +</template> diff --git a/app/assets/javascripts/issues/show/constants.js b/app/assets/javascripts/issues/show/constants.js new file mode 100644 index 00000000000..35f3bcdad70 --- /dev/null +++ b/app/assets/javascripts/issues/show/constants.js @@ -0,0 +1,22 @@ +import { __ } from '~/locale'; + +export const IssueStateEvent = { + Close: 'CLOSE', + Reopen: 'REOPEN', +}; + +export const STATUS_PAGE_PUBLISHED = __('Published on status page'); +export const JOIN_ZOOM_MEETING = __('Join Zoom meeting'); + +export const IssuableTypes = [ + { value: 'issue', text: __('Issue'), icon: 'issue-type-issue' }, + { value: 'incident', text: __('Incident'), icon: 'issue-type-incident' }, +]; + +export const IssueTypePath = 'issues'; +export const IncidentTypePath = 'issues/incident'; +export const IncidentType = 'incident'; + +export const issueState = { issueType: undefined, isDirty: false }; + +export const POLLING_DELAY = 2000; diff --git a/app/assets/javascripts/issues/show/event_hub.js b/app/assets/javascripts/issues/show/event_hub.js new file mode 100644 index 00000000000..e31806ad199 --- /dev/null +++ b/app/assets/javascripts/issues/show/event_hub.js @@ -0,0 +1,3 @@ +import createEventHub from '~/helpers/event_hub_factory'; + +export default createEventHub(); diff --git a/app/assets/javascripts/issues/show/graphql.js b/app/assets/javascripts/issues/show/graphql.js new file mode 100644 index 00000000000..5b8630f7d63 --- /dev/null +++ b/app/assets/javascripts/issues/show/graphql.js @@ -0,0 +1,9 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { defaultClient } from '~/sidebar/graphql'; + +Vue.use(VueApollo); + +export default new VueApollo({ + defaultClient, +}); diff --git a/app/assets/javascripts/issues/show/incident.js b/app/assets/javascripts/issues/show/incident.js new file mode 100644 index 00000000000..a260c31e1da --- /dev/null +++ b/app/assets/javascripts/issues/show/incident.js @@ -0,0 +1,101 @@ +import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import issuableApp from './components/app.vue'; +import incidentTabs from './components/incidents/incident_tabs.vue'; +import { issueState, IncidentType } from './constants'; +import apolloProvider from './graphql'; +import getIssueStateQuery from './queries/get_issue_state.query.graphql'; +import HeaderActions from './components/header_actions.vue'; + +const bootstrapApollo = (state = {}) => { + return apolloProvider.clients.defaultClient.cache.writeQuery({ + query: getIssueStateQuery, + data: { + issueState: state, + }, + }); +}; + +export function initIncidentApp(issuableData = {}) { + const el = document.getElementById('js-issuable-app'); + + if (!el) { + return undefined; + } + + bootstrapApollo({ ...issueState, issueType: el.dataset.issueType }); + + const { + canCreateIncident, + canUpdate, + iid, + projectNamespace, + projectPath, + projectId, + slaFeatureAvailable, + uploadMetricsFeatureAvailable, + } = issuableData; + + const fullPath = `${projectNamespace}/${projectPath}`; + + return new Vue({ + el, + apolloProvider, + components: { + issuableApp, + }, + provide: { + issueType: IncidentType, + canCreateIncident, + canUpdate, + fullPath, + iid, + projectId, + slaFeatureAvailable: parseBoolean(slaFeatureAvailable), + uploadMetricsFeatureAvailable: parseBoolean(uploadMetricsFeatureAvailable), + }, + render(createElement) { + return createElement('issuable-app', { + props: { + ...issuableData, + descriptionComponent: incidentTabs, + showTitleBorder: false, + }, + }); + }, + }); +} + +export function initIncidentHeaderActions(store) { + const el = document.querySelector('.js-issue-header-actions'); + + if (!el) { + return undefined; + } + + bootstrapApollo({ ...issueState, issueType: el.dataset.issueType }); + + return new Vue({ + el, + apolloProvider, + store, + provide: { + canCreateIssue: parseBoolean(el.dataset.canCreateIncident), + canDestroyIssue: parseBoolean(el.dataset.canDestroyIssue), + canPromoteToEpic: parseBoolean(el.dataset.canPromoteToEpic), + canReopenIssue: parseBoolean(el.dataset.canReopenIssue), + canReportSpam: parseBoolean(el.dataset.canReportSpam), + canUpdateIssue: parseBoolean(el.dataset.canUpdateIssue), + iid: el.dataset.iid, + isIssueAuthor: parseBoolean(el.dataset.isIssueAuthor), + issuePath: el.dataset.issuePath, + issueType: el.dataset.issueType, + newIssuePath: el.dataset.newIssuePath, + projectPath: el.dataset.projectPath, + projectId: el.dataset.projectId, + reportAbusePath: el.dataset.reportAbusePath, + submitAsSpamPath: el.dataset.submitAsSpamPath, + }, + render: (createElement) => createElement(HeaderActions), + }); +} diff --git a/app/assets/javascripts/issues/show/issue.js b/app/assets/javascripts/issues/show/issue.js new file mode 100644 index 00000000000..60e90934af8 --- /dev/null +++ b/app/assets/javascripts/issues/show/issue.js @@ -0,0 +1,86 @@ +import Vue from 'vue'; +import { mapGetters } from 'vuex'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import IssuableApp from './components/app.vue'; +import HeaderActions from './components/header_actions.vue'; +import { issueState } from './constants'; +import apolloProvider from './graphql'; +import getIssueStateQuery from './queries/get_issue_state.query.graphql'; + +const bootstrapApollo = (state = {}) => { + return apolloProvider.clients.defaultClient.cache.writeQuery({ + query: getIssueStateQuery, + data: { + issueState: state, + }, + }); +}; + +export function initIssuableApp(issuableData, store) { + const el = document.getElementById('js-issuable-app'); + + if (!el) { + return undefined; + } + + bootstrapApollo({ ...issueState, issueType: el.dataset.issueType }); + + const { canCreateIncident, ...issuableProps } = issuableData; + + return new Vue({ + el, + apolloProvider, + store, + provide: { + canCreateIncident, + }, + computed: { + ...mapGetters(['getNoteableData']), + }, + render(createElement) { + return createElement(IssuableApp, { + props: { + ...issuableProps, + isConfidential: this.getNoteableData?.confidential, + isLocked: this.getNoteableData?.discussion_locked, + issuableStatus: this.getNoteableData?.state, + id: this.getNoteableData?.id, + }, + }); + }, + }); +} + +export function initIssueHeaderActions(store) { + const el = document.querySelector('.js-issue-header-actions'); + + if (!el) { + return undefined; + } + + bootstrapApollo({ ...issueState, issueType: el.dataset.issueType }); + + return new Vue({ + el, + apolloProvider, + store, + provide: { + canCreateIssue: parseBoolean(el.dataset.canCreateIssue), + canDestroyIssue: parseBoolean(el.dataset.canDestroyIssue), + canPromoteToEpic: parseBoolean(el.dataset.canPromoteToEpic), + canReopenIssue: parseBoolean(el.dataset.canReopenIssue), + canReportSpam: parseBoolean(el.dataset.canReportSpam), + canUpdateIssue: parseBoolean(el.dataset.canUpdateIssue), + iid: el.dataset.iid, + isIssueAuthor: parseBoolean(el.dataset.isIssueAuthor), + issuePath: el.dataset.issuePath, + issueType: el.dataset.issueType, + newIssuePath: el.dataset.newIssuePath, + projectPath: el.dataset.projectPath, + projectId: el.dataset.projectId, + reportAbusePath: el.dataset.reportAbusePath, + submitAsSpamPath: el.dataset.submitAsSpamPath, + }, + render: (createElement) => createElement(HeaderActions), + }); +} diff --git a/app/assets/javascripts/issues/show/mixins/animate.js b/app/assets/javascripts/issues/show/mixins/animate.js new file mode 100644 index 00000000000..4816393da1f --- /dev/null +++ b/app/assets/javascripts/issues/show/mixins/animate.js @@ -0,0 +1,13 @@ +export default { + methods: { + animateChange() { + this.preAnimation = true; + this.pulseAnimation = false; + + setTimeout(() => { + this.preAnimation = false; + this.pulseAnimation = true; + }); + }, + }, +}; diff --git a/app/assets/javascripts/issues/show/mixins/update.js b/app/assets/javascripts/issues/show/mixins/update.js new file mode 100644 index 00000000000..72be65b426f --- /dev/null +++ b/app/assets/javascripts/issues/show/mixins/update.js @@ -0,0 +1,10 @@ +import eventHub from '../event_hub'; + +export default { + methods: { + updateIssuable() { + this.formState.updateLoading = true; + eventHub.$emit('update.issuable'); + }, + }, +}; diff --git a/app/assets/javascripts/issues/show/queries/get_issue_state.query.graphql b/app/assets/javascripts/issues/show/queries/get_issue_state.query.graphql new file mode 100644 index 00000000000..33b737d2315 --- /dev/null +++ b/app/assets/javascripts/issues/show/queries/get_issue_state.query.graphql @@ -0,0 +1,3 @@ +query issueState { + issueState @client +} diff --git a/app/assets/javascripts/issues/show/queries/promote_to_epic.mutation.graphql b/app/assets/javascripts/issues/show/queries/promote_to_epic.mutation.graphql new file mode 100644 index 00000000000..e3e3a2bc667 --- /dev/null +++ b/app/assets/javascripts/issues/show/queries/promote_to_epic.mutation.graphql @@ -0,0 +1,9 @@ +mutation promoteToEpic($input: PromoteToEpicInput!) { + promoteToEpic(input: $input) { + epic { + id + webPath + } + errors + } +} diff --git a/app/assets/javascripts/issues/show/queries/update_issue.mutation.graphql b/app/assets/javascripts/issues/show/queries/update_issue.mutation.graphql new file mode 100644 index 00000000000..ec8d8f32d8b --- /dev/null +++ b/app/assets/javascripts/issues/show/queries/update_issue.mutation.graphql @@ -0,0 +1,9 @@ +mutation updateIssue($input: UpdateIssueInput!) { + updateIssue(input: $input) { + issuable: issue { + id + state + } + errors + } +} diff --git a/app/assets/javascripts/issues/show/queries/update_issue_state.mutation.graphql b/app/assets/javascripts/issues/show/queries/update_issue_state.mutation.graphql new file mode 100644 index 00000000000..d91ca746066 --- /dev/null +++ b/app/assets/javascripts/issues/show/queries/update_issue_state.mutation.graphql @@ -0,0 +1,3 @@ +mutation updateIssueState($issueType: String, $isDirty: Boolean) { + updateIssueState(issueType: $issueType, isDirty: $isDirty) @client +} diff --git a/app/assets/javascripts/issues/show/services/index.js b/app/assets/javascripts/issues/show/services/index.js new file mode 100644 index 00000000000..dba07f623f9 --- /dev/null +++ b/app/assets/javascripts/issues/show/services/index.js @@ -0,0 +1,29 @@ +import axios from '~/lib/utils/axios_utils'; + +export default class Service { + constructor(endpoint) { + this.endpoint = `${endpoint}.json`; + this.realtimeEndpoint = `${endpoint}/realtime_changes`; + } + + getData() { + return axios.get(this.realtimeEndpoint); + } + + deleteIssuable(payload) { + return axios.delete(this.endpoint, { params: payload }); + } + + updateIssuable(data) { + return axios.put(this.endpoint, data); + } + + // eslint-disable-next-line class-methods-use-this + loadTemplates(templateNamesEndpoint) { + if (!templateNamesEndpoint) { + return Promise.resolve([]); + } + + return axios.get(templateNamesEndpoint); + } +} diff --git a/app/assets/javascripts/issues/show/stores/index.js b/app/assets/javascripts/issues/show/stores/index.js new file mode 100644 index 00000000000..a50913d3455 --- /dev/null +++ b/app/assets/javascripts/issues/show/stores/index.js @@ -0,0 +1,46 @@ +import { sanitize } from '~/lib/dompurify'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import updateDescription from '../utils/update_description'; + +export default class Store { + constructor(initialState) { + this.state = initialState; + this.formState = { + title: '', + description: '', + lockedWarningVisible: false, + updateLoading: false, + lock_version: 0, + issuableTemplates: {}, + }; + } + + updateState(data) { + if (this.stateShouldUpdate(data)) { + this.formState.lockedWarningVisible = true; + } + + Object.assign(this.state, convertObjectPropsToCamelCase(data)); + // find if there is an open details node inside of the issue description. + const descriptionSection = document.body.querySelector( + '.detail-page-description.content-block', + ); + const details = + descriptionSection != null && descriptionSection.getElementsByTagName('details'); + + this.state.descriptionHtml = updateDescription(sanitize(data.description), details); + this.state.titleHtml = sanitize(data.title); + this.state.lock_version = data.lock_version; + } + + stateShouldUpdate(data) { + return ( + this.state.titleText !== data.title_text || + this.state.descriptionText !== data.description_text + ); + } + + setFormState(state) { + this.formState = Object.assign(this.formState, state); + } +} diff --git a/app/assets/javascripts/issues/show/utils/parse_data.js b/app/assets/javascripts/issues/show/utils/parse_data.js new file mode 100644 index 00000000000..f1e6bd2419a --- /dev/null +++ b/app/assets/javascripts/issues/show/utils/parse_data.js @@ -0,0 +1,23 @@ +import * as Sentry from '@sentry/browser'; +import { sanitize } from '~/lib/dompurify'; + +// We currently load + parse the data from the issue app and related merge request +let cachedParsedData; + +export const parseIssuableData = (el) => { + try { + if (cachedParsedData) return cachedParsedData; + + const parsedData = JSON.parse(el.dataset.initial); + parsedData.initialTitleHtml = sanitize(parsedData.initialTitleHtml); + parsedData.initialDescriptionHtml = sanitize(parsedData.initialDescriptionHtml); + + cachedParsedData = parsedData; + + return parsedData; + } catch (e) { + Sentry.captureException(e); + + return {}; + } +}; diff --git a/app/assets/javascripts/issues/show/utils/update_description.js b/app/assets/javascripts/issues/show/utils/update_description.js new file mode 100644 index 00000000000..c5811290e61 --- /dev/null +++ b/app/assets/javascripts/issues/show/utils/update_description.js @@ -0,0 +1,36 @@ +/** + * Function that replaces the open attribute for the <details> element. + * + * @param {String} descriptionHtml - The html string passed back from the server as a result of polling + * @param {Array} details - All detail nodes inside of the issue description. + */ + +const updateDescription = (descriptionHtml = '', details) => { + let detailNodes = details; + + if (!details.length) { + detailNodes = []; + } + + const placeholder = document.createElement('div'); + placeholder.innerHTML = descriptionHtml; + + const newDetails = placeholder.getElementsByTagName('details'); + + if (newDetails.length !== detailNodes.length) { + return descriptionHtml; + } + + Array.from(newDetails).forEach((el, i) => { + /* + * <details> has an open attribute that can have a value, "", "true", "false" + * and will show the dropdown, which is why we are setting the attribute + * explicitly to true. + */ + if (detailNodes[i].open) el.setAttribute('open', true); + }); + + return placeholder.innerHTML; +}; + +export default updateDescription; |