Commit 50e21a89 authored by Phil Hughes's avatar Phil Hughes

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
parent 15b4a8f9
<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>
<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 }} &bull;
<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">
&bull; {{ __('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>
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,
},
});
},
});
}
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
}
}
}
}
}
}
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,
},
});
......@@ -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();
}
};
......@@ -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;
}
}
......@@ -38,6 +38,8 @@ class Projects::IssuesController < Projects::ApplicationController
# Allow create a new branch and empty WIP merge request from current issue
before_action :authorize_create_merge_request_from!, only: [:create_merge_request]
before_action :set_suggested_issues_feature_flags, only: [:new]
respond_to :html
def index
......@@ -263,4 +265,9 @@ class Projects::IssuesController < Projects::ApplicationController
# 3. https://gitlab.com/gitlab-org/gitlab-ce/issues/42426
Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42422')
end
def set_suggested_issues_feature_flags
push_frontend_feature_flag(:graphql)
push_frontend_feature_flag(:issue_suggestions)
end
end
# frozen_string_literal: true
module Resolvers
class IssuesResolver < BaseResolver
extend ActiveSupport::Concern
argument :search, GraphQL::STRING_TYPE,
required: false
argument :sort, Types::Sort,
required: false,
default_value: 'created_desc'
type Types::IssueType, null: true
alias_method :project, :object
def resolve(**args)
# Will need to be be made group & namespace aware with
# https://gitlab.com/gitlab-org/gitlab-ce/issues/54520
args[:project_id] = project.id
IssuesFinder.new(context[:current_user], args).execute
end
end
end
# frozen_string_literal: true
module Types
class IssueType < BaseObject
expose_permissions Types::PermissionTypes::Issue
graphql_name 'Issue'
present_using IssuePresenter
field :iid, GraphQL::ID_TYPE, null: false
field :title, GraphQL::STRING_TYPE, null: false
field :description, GraphQL::STRING_TYPE, null: true
field :state, GraphQL::STRING_TYPE, null: false
field :author, Types::UserType,
null: false,
resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, obj.author_id).find } do
authorize :read_user
end
field :assignees, Types::UserType.connection_type, null: true
field :labels, Types::LabelType.connection_type, null: true
field :milestone, Types::MilestoneType,
null: true,
resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Milestone, obj.milestone_id).find } do
authorize :read_milestone
end
field :due_date, Types::TimeType, null: true
field :confidential, GraphQL::BOOLEAN_TYPE, null: false
field :discussion_locked, GraphQL::BOOLEAN_TYPE,
null: false,
resolve: -> (obj, _args, _ctx) { !!obj.discussion_locked }
field :upvotes, GraphQL::INT_TYPE, null: false
field :downvotes, GraphQL::INT_TYPE, null: false
field :user_notes_count, GraphQL::INT_TYPE, null: false
field :web_url, GraphQL::STRING_TYPE, null: false
field :closed_at, Types::TimeType, null: true
field :created_at, Types::TimeType, null: false
field :updated_at, Types::TimeType, null: false
end
end
# frozen_string_literal: true
module Types
class LabelType < BaseObject
graphql_name 'Label'
field :description, GraphQL::STRING_TYPE, null: true
field :title, GraphQL::STRING_TYPE, null: false
field :color, GraphQL::STRING_TYPE, null: false
field :text_color, GraphQL::STRING_TYPE, null: false
end
end
# frozen_string_literal: true
module Types
class MilestoneType < BaseObject
graphql_name 'Milestone'
field :description, GraphQL::STRING_TYPE, null: true
field :title, GraphQL::STRING_TYPE, null: false
field :state, GraphQL::STRING_TYPE, null: false
field :due_date, Types::TimeType, null: true
field :start_date, Types::TimeType, null: true
field :created_at, Types::TimeType, null: false
field :updated_at, Types::TimeType, null: false
end
end
# frozen_string_literal: true
module Types
class Types::Order < Types::BaseEnum
value "id", "Created at date"
value "updated_at", "Updated at date"
end
end
# frozen_string_literal: true
module Types
module PermissionTypes
class Issue < BasePermissionType
description 'Check permissions for the current user on a issue'
graphql_name 'IssuePermissions'
abilities :read_issue, :admin_issue,
:update_issue, :create_note,
:reopen_issue
end
end
end
......@@ -73,6 +73,11 @@ module Types
authorize :read_merge_request
end
field :issues,
Types::IssueType.connection_type,
null: true,
resolver: Resolvers::IssuesResolver
field :pipelines,
Types::Ci::PipelineType.connection_type,
null: false,
......
# frozen_string_literal: true
module Types
class Types::Sort < Types::BaseEnum
value "updated_desc", "Updated at descending order"
value "updated_asc", "Updated at ascending order"
value "created_desc", "Created at descending order"
value "created_asc", "Created at ascending order"
end
end
# frozen_string_literal: true
module Types
class UserType < BaseObject
graphql_name 'User'
present_using UserPresenter
field :name, GraphQL::STRING_TYPE, null: false
field :username, GraphQL::STRING_TYPE, null: false
field :avatar_url, GraphQL::STRING_TYPE, null: false
field :web_url, GraphQL::STRING_TYPE, null: false
end
end
# frozen_string_literal: true
class MilestonePolicy < BasePolicy
delegate { @subject.project }
end
# frozen_string_literal: true
class IssuePresenter < Gitlab::View::Presenter::Delegated
presents :issue
def web_url
Gitlab::UrlBuilder.build(issue)
end
end
# frozen_string_literal: true
class UserPresenter < Gitlab::View::Presenter::Delegated
presents :user
def web_url
Gitlab::Routing.url_helpers.user_url(user)
end
end
......@@ -17,6 +17,8 @@
= render 'shared/issuable/form/template_selector', issuable: issuable
= render 'shared/issuable/form/title', issuable: issuable, form: form, has_wip_commits: commits && commits.detect(&:work_in_progress?)
- if Feature.enabled?(:issue_suggestions) && Feature.enabled?(:graphql)
#js-suggestions{ data: { project_path: @project.full_path } }
= render 'shared/form_elements/description', model: issuable, form: form, project: project
......
......@@ -84,7 +84,7 @@ module.exports = {
},
resolve: {
extensions: ['.js'],
extensions: ['.js', '.gql', '.graphql'],
alias: {
'~': path.join(ROOT_PATH, 'app/assets/javascripts'),
emojis: path.join(ROOT_PATH, 'fixtures/emojis'),
......@@ -100,6 +100,11 @@ module.exports = {
module: {
strictExportPresence: true,
rules: [
{
type: 'javascript/auto',
test: /\.mjs$/,
use: [],
},
{
test: /\.js$/,
exclude: path => /node_modules|vendor[\\/]assets/.test(path) && !/\.vue\.js/.test(path),
......@@ -121,6 +126,11 @@ module.exports = {
].join('|'),
},
},
{
test: /\.(graphql|gql)$/,
exclude: /node_modules/,
loader: 'graphql-tag/loader',
},
{
test: /\.svg$/,
loader: 'raw-loader',
......
# frozen_string_literal: true
module Gitlab
module Graphql
module Loaders
class BatchModelLoader
attr_reader :model_class, :model_id
def initialize(model_class, model_id)
@model_class, @model_id = model_class, model_id
end
# rubocop: disable CodeReuse/ActiveRecord
def find
BatchLoader.for({ model: model_class, id: model_id }).batch do |loader_info, loader|
per_model = loader_info.group_by { |info| info[:model] }
per_model.each do |model, info|
ids = info.map { |i| i[:id] }
results = model.where(id: ids)
results.each { |record| loader.call({ model: model, id: record.id }, record) }
end
end
end
# rubocop: enable CodeReuse/ActiveRecord
end
end
end
end
......@@ -1379,6 +1379,9 @@ msgstr ""
msgid "Close"
msgstr ""
msgid "Closed"
msgstr ""
msgid "ClusterIntegration|%{appList} was successfully installed on your Kubernetes cluster"
msgstr ""
......@@ -4467,6 +4470,9 @@ msgstr ""
msgid "Open source software to collaborate on code"
msgstr ""
msgid "Opened"
msgstr ""
msgid "OpenedNDaysAgo|Opened"
msgstr ""
......@@ -5856,6 +5862,9 @@ msgstr ""
msgid "Sign-up restrictions"
msgstr ""
msgid "Similar issues"
msgstr ""
msgid "Size and domain settings for static websites"
msgstr ""
......@@ -6392,6 +6401,9 @@ msgstr ""
msgid "There was an error when unsubscribing from this label."
msgstr ""
msgid "These existing issues have a similar title. It might be better to comment there instead of creating another similar issue."
msgstr ""
msgid "They can be managed using the %{link}."
msgstr ""
......@@ -7840,6 +7852,9 @@ msgstr ""
msgid "this document"