diff options
author | Filipa Lacerda <filipa@gitlab.com> | 2018-11-27 17:58:27 +0000 |
---|---|---|
committer | Filipa Lacerda <filipa@gitlab.com> | 2018-11-27 17:58:27 +0000 |
commit | a99f342b4231659d39b9a145acc3652f3de3bce8 (patch) | |
tree | 096b376fac6ccd6519f1e60720ba67d56e1bef75 /app | |
parent | d0b529d17a507709467cc75c607c19d465f9852d (diff) | |
parent | 50e21a89a0009813b9f090288b22c64c5cefbd58 (diff) | |
download | gitlab-ce-a99f342b4231659d39b9a145acc3652f3de3bce8.tar.gz |
Merge branch 'issuable-suggestions' into 'master'
Suggest issues when typing title
Closes #22071
See merge request gitlab-org/gitlab-ce!22866
Diffstat (limited to 'app')
21 files changed, 529 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; + } +} diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 308f666394c..d6d7110355b 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -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 diff --git a/app/graphql/resolvers/issues_resolver.rb b/app/graphql/resolvers/issues_resolver.rb new file mode 100644 index 00000000000..4ab3c13787a --- /dev/null +++ b/app/graphql/resolvers/issues_resolver.rb @@ -0,0 +1,25 @@ +# 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 diff --git a/app/graphql/types/issue_type.rb b/app/graphql/types/issue_type.rb new file mode 100644 index 00000000000..a8f2f7914a8 --- /dev/null +++ b/app/graphql/types/issue_type.rb @@ -0,0 +1,47 @@ +# 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 diff --git a/app/graphql/types/label_type.rb b/app/graphql/types/label_type.rb new file mode 100644 index 00000000000..ccd466edc1a --- /dev/null +++ b/app/graphql/types/label_type.rb @@ -0,0 +1,12 @@ +# 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 diff --git a/app/graphql/types/milestone_type.rb b/app/graphql/types/milestone_type.rb new file mode 100644 index 00000000000..af31b572c9a --- /dev/null +++ b/app/graphql/types/milestone_type.rb @@ -0,0 +1,17 @@ +# 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 diff --git a/app/graphql/types/order.rb b/app/graphql/types/order.rb new file mode 100644 index 00000000000..c5e1cc406b4 --- /dev/null +++ b/app/graphql/types/order.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Types + class Types::Order < Types::BaseEnum + value "id", "Created at date" + value "updated_at", "Updated at date" + end +end diff --git a/app/graphql/types/permission_types/issue.rb b/app/graphql/types/permission_types/issue.rb new file mode 100644 index 00000000000..199540c7d6d --- /dev/null +++ b/app/graphql/types/permission_types/issue.rb @@ -0,0 +1,14 @@ +# 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 diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb index 7b879608b34..050706f97be 100644 --- a/app/graphql/types/project_type.rb +++ b/app/graphql/types/project_type.rb @@ -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, diff --git a/app/graphql/types/sort.rb b/app/graphql/types/sort.rb new file mode 100644 index 00000000000..1f756fdab69 --- /dev/null +++ b/app/graphql/types/sort.rb @@ -0,0 +1,10 @@ +# 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 diff --git a/app/graphql/types/user_type.rb b/app/graphql/types/user_type.rb new file mode 100644 index 00000000000..a13e65207df --- /dev/null +++ b/app/graphql/types/user_type.rb @@ -0,0 +1,14 @@ +# 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 diff --git a/app/policies/milestone_policy.rb b/app/policies/milestone_policy.rb new file mode 100644 index 00000000000..ac4f5b08504 --- /dev/null +++ b/app/policies/milestone_policy.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class MilestonePolicy < BasePolicy + delegate { @subject.project } +end diff --git a/app/presenters/issue_presenter.rb b/app/presenters/issue_presenter.rb new file mode 100644 index 00000000000..c12a202efbc --- /dev/null +++ b/app/presenters/issue_presenter.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class IssuePresenter < Gitlab::View::Presenter::Delegated + presents :issue + + def web_url + Gitlab::UrlBuilder.build(issue) + end +end diff --git a/app/presenters/user_presenter.rb b/app/presenters/user_presenter.rb new file mode 100644 index 00000000000..14ef53e9ec8 --- /dev/null +++ b/app/presenters/user_presenter.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class UserPresenter < Gitlab::View::Presenter::Delegated + presents :user + + def web_url + Gitlab::Routing.url_helpers.user_url(user) + end +end diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index b33c758b464..1618655182c 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -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 |