diff options
author | Phil Hughes <me@iamphill.com> | 2018-11-27 15:10:40 +0000 |
---|---|---|
committer | Phil Hughes <me@iamphill.com> | 2018-11-27 15:10:40 +0000 |
commit | 50e21a89a0009813b9f090288b22c64c5cefbd58 (patch) | |
tree | 7a142c12573f872f14ac366ec00082c650664592 /app/assets | |
parent | 15b4a8f93aaf313b8197ca381f529b00bd231a20 (diff) | |
download | gitlab-ce-50e21a89a0009813b9f090288b22c64c5cefbd58.tar.gz |
Suggests issues when typing title
This suggests possibly related issues when the user types a title.
This uses GraphQL to allow the frontend to request the exact
data that is requires. We also get free caching through the Vue Apollo
plugin.
With this we can include the ability to import .graphql files in JS
and Vue files.
Also we now have the Vue test utils library to make testing
Vue components easier.
Closes #22071
Diffstat (limited to 'app/assets')
7 files changed, 345 insertions, 0 deletions
diff --git a/app/assets/javascripts/issuable_suggestions/components/app.vue b/app/assets/javascripts/issuable_suggestions/components/app.vue new file mode 100644 index 00000000000..eea0701312b --- /dev/null +++ b/app/assets/javascripts/issuable_suggestions/components/app.vue @@ -0,0 +1,96 @@ +<script> +import _ from 'underscore'; +import { GlTooltipDirective } from '@gitlab/ui'; +import { __ } from '~/locale'; +import Icon from '~/vue_shared/components/icon.vue'; +import Suggestion from './item.vue'; +import query from '../queries/issues.graphql'; + +export default { + components: { + Suggestion, + Icon, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + projectPath: { + type: String, + required: true, + }, + search: { + type: String, + required: true, + }, + }, + apollo: { + issues: { + query, + debounce: 250, + 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 _.isEmpty(this.search); + }, + 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 issuable-suggestions"> + <div v-once class="col-form-label col-sm-2 pt-0"> + {{ __('Similar issues') }} + <icon + v-gl-tooltip.bottom + :title="$options.helpText" + :aria-label="$options.helpText" + name="question-o" + class="text-secondary suggestion-help-hover" + /> + </div> + <div class="col-sm-10"> + <ul class="list-unstyled m-0"> + <li + v-for="(suggestion, index) in issues" + :key="suggestion.id" + :class="{ + 'append-bottom-default': index !== issues.length - 1, + }" + > + <suggestion :suggestion="suggestion" /> + </li> + </ul> + </div> + </div> +</template> diff --git a/app/assets/javascripts/issuable_suggestions/components/item.vue b/app/assets/javascripts/issuable_suggestions/components/item.vue new file mode 100644 index 00000000000..9a16b486bf5 --- /dev/null +++ b/app/assets/javascripts/issuable_suggestions/components/item.vue @@ -0,0 +1,137 @@ +<script> +import _ from 'underscore'; +import { GlLink, GlTooltip, GlTooltipDirective } from '@gitlab/ui'; +import { __ } from '~/locale'; +import Icon from '~/vue_shared/components/icon.vue'; +import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue'; +import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import timeago from '~/vue_shared/mixins/timeago'; + +export default { + components: { + GlTooltip, + GlLink, + Icon, + UserAvatarImage, + TimeagoTooltip, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + mixins: [timeago], + props: { + suggestion: { + type: Object, + required: true, + }, + }, + computed: { + isOpen() { + return this.suggestion.state === 'opened'; + }, + isClosed() { + return this.suggestion.state === 'closed'; + }, + 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); + }, + stateIcon() { + 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"> + <icon + v-if="suggestion.confidential" + v-gl-tooltip.bottom + :title="__('Confidential')" + name="eye-slash" + class="suggestion-help-hover mr-1 suggestion-confidential" + /> + <gl-link :href="suggestion.webUrl" target="_blank" class="suggestion bold str-truncated-100"> + {{ suggestion.title }} + </gl-link> + </div> + <div class="text-secondary suggestion-footer"> + <icon + ref="state" + :name="stateIcon" + :class="{ + 'suggestion-state-open': isOpen, + 'suggestion-state-closed': isClosed, + }" + class="suggestion-help-hover" + /> + <gl-tooltip :target="() => $refs.state" placement="bottom"> + <span class="d-block"> + <span class="bold"> {{ stateTitle }} </span> {{ timeFormated(closedOrCreatedDate) }} + </span> + <span class="text-tertiary">{{ tooltipTitle(closedOrCreatedDate) }}</span> + </gl-tooltip> + #{{ suggestion.iid }} • + <timeago-tooltip + :time="suggestion.createdAt" + tooltip-placement="bottom" + class="suggestion-help-hover" + /> + 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="suggestion-help-hover" + /> + </template> + <span class="suggestion-counts"> + <span + v-for="{ count, icon, tooltipTitle, id } in counts" + :key="id" + v-gl-tooltip.bottom + :title="tooltipTitle" + class="suggestion-help-hover prepend-left-8 text-tertiary" + > + <icon :name="icon" /> {{ count }} + </span> + </span> + </div> + </div> +</template> diff --git a/app/assets/javascripts/issuable_suggestions/index.js b/app/assets/javascripts/issuable_suggestions/index.js new file mode 100644 index 00000000000..2c80cf1797a --- /dev/null +++ b/app/assets/javascripts/issuable_suggestions/index.js @@ -0,0 +1,38 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import defaultClient from '~/lib/graphql'; +import App from './components/app.vue'; + +Vue.use(VueApollo); + +export default function() { + const el = document.getElementById('js-suggestions'); + const issueTitle = document.getElementById('issue_title'); + const { projectPath } = el.dataset; + const apolloProvider = new VueApollo({ + defaultClient, + }); + + return new Vue({ + el, + apolloProvider, + data() { + return { + search: issueTitle.value, + }; + }, + mounted() { + issueTitle.addEventListener('input', () => { + this.search = issueTitle.value; + }); + }, + render(h) { + return h(App, { + props: { + projectPath, + search: this.search, + }, + }); + }, + }); +} diff --git a/app/assets/javascripts/issuable_suggestions/queries/issues.graphql b/app/assets/javascripts/issuable_suggestions/queries/issues.graphql new file mode 100644 index 00000000000..2384b381344 --- /dev/null +++ b/app/assets/javascripts/issuable_suggestions/queries/issues.graphql @@ -0,0 +1,26 @@ +query issueSuggestion($fullPath: ID!, $search: String) { + project(fullPath: $fullPath) { + issues(search: $search, sort: updated_desc, first: 5) { + edges { + node { + iid + title + confidential + userNotesCount + upvotes + webUrl + state + closedAt + createdAt + updatedAt + author { + name + username + avatarUrl + webUrl + } + } + } + } + } +} diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js new file mode 100644 index 00000000000..20a0f142d9e --- /dev/null +++ b/app/assets/javascripts/lib/graphql.js @@ -0,0 +1,9 @@ +import ApolloClient from 'apollo-boost'; +import csrf from '~/lib/utils/csrf'; + +export default new ApolloClient({ + uri: `${gon.relative_url_root}/api/graphql`, + headers: { + [csrf.headerKey]: csrf.token, + }, +}); diff --git a/app/assets/javascripts/pages/projects/issues/form.js b/app/assets/javascripts/pages/projects/issues/form.js index 197bfa8a394..02a56685a35 100644 --- a/app/assets/javascripts/pages/projects/issues/form.js +++ b/app/assets/javascripts/pages/projects/issues/form.js @@ -7,6 +7,7 @@ import LabelsSelect from '~/labels_select'; import MilestoneSelect from '~/milestone_select'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import IssuableTemplateSelectors from '~/templates/issuable_template_selectors'; +import initSuggestions from '~/issuable_suggestions'; export default () => { new ShortcutsNavigation(); @@ -15,4 +16,8 @@ export default () => { new LabelsSelect(); new MilestoneSelect(); new IssuableTemplateSelectors(); + + if (gon.features.issueSuggestions && gon.features.graphql) { + initSuggestions(); + } }; diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 78c5ae9ae63..5b5f486ea63 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -938,3 +938,37 @@ } } } + +.issuable-suggestions svg { + vertical-align: sub; +} + +.suggestion-item a { + color: initial; +} + +.suggestion-confidential { + color: $orange-600; +} + +.suggestion-state-open { + color: $green-500; +} + +.suggestion-state-closed { + color: $blue-500; +} + +.suggestion-help-hover { + cursor: help; +} + +.suggestion-footer { + font-size: 12px; + line-height: 15px; + + .avatar { + margin-top: -3px; + border: 0; + } +} |