diff options
Diffstat (limited to 'app/assets/javascripts/issues/new')
5 files changed, 352 insertions, 0 deletions
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 + } + } + } + } + } +} |