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'; ...@@ -7,6 +7,7 @@ import LabelsSelect from '~/labels_select';
import MilestoneSelect from '~/milestone_select'; import MilestoneSelect from '~/milestone_select';
import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation';
import IssuableTemplateSelectors from '~/templates/issuable_template_selectors'; import IssuableTemplateSelectors from '~/templates/issuable_template_selectors';
import initSuggestions from '~/issuable_suggestions';
export default () => { export default () => {
new ShortcutsNavigation(); new ShortcutsNavigation();
...@@ -15,4 +16,8 @@ export default () => { ...@@ -15,4 +16,8 @@ export default () => {
new LabelsSelect(); new LabelsSelect();
new MilestoneSelect(); new MilestoneSelect();
new IssuableTemplateSelectors(); new IssuableTemplateSelectors();
if (gon.features.issueSuggestions && gon.features.graphql) {
initSuggestions();
}
}; };
...@@ -938,3 +938,37 @@ ...@@ -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 ...@@ -38,6 +38,8 @@ class Projects::IssuesController < Projects::ApplicationController
# Allow create a new branch and empty WIP merge request from current issue # 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 :authorize_create_merge_request_from!, only: [:create_merge_request]
before_action :set_suggested_issues_feature_flags, only: [:new]
respond_to :html respond_to :html
def index def index
...@@ -263,4 +265,9 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -263,4 +265,9 @@ class Projects::IssuesController < Projects::ApplicationController
# 3. https://gitlab.com/gitlab-org/gitlab-ce/issues/42426 # 3. https://gitlab.com/gitlab-org/gitlab-ce/issues/42426
Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42422') Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42422')
end end
def set_suggested_issues_feature_flags
push_frontend_feature_flag(:graphql)
push_frontend_feature_flag(:issue_suggestions)
end
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 ...@@ -73,6 +73,11 @@ module Types
authorize :read_merge_request authorize :read_merge_request
end end
field :issues,
Types::IssueType.connection_type,
null: true,
resolver: Resolvers::IssuesResolver
field :pipelines, field :pipelines,
Types::Ci::PipelineType.connection_type, Types::Ci::PipelineType.connection_type,
null: false, 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 @@ ...@@ -17,6 +17,8 @@
= render 'shared/issuable/form/template_selector', issuable: issuable = 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?) = 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 = render 'shared/form_elements/description', model: issuable, form: form, project: project
......
...@@ -84,7 +84,7 @@ module.exports = { ...@@ -84,7 +84,7 @@ module.exports = {
}, },
resolve: { resolve: {
extensions: ['.js'], extensions: ['.js', '.gql', '.graphql'],
alias: { alias: {
'~': path.join(ROOT_PATH, 'app/assets/javascripts'), '~': path.join(ROOT_PATH, 'app/assets/javascripts'),
emojis: path.join(ROOT_PATH, 'fixtures/emojis'), emojis: path.join(ROOT_PATH, 'fixtures/emojis'),
...@@ -100,6 +100,11 @@ module.exports = { ...@@ -100,6 +100,11 @@ module.exports = {
module: { module: {
strictExportPresence: true, strictExportPresence: true,
rules: [ rules: [
{
type: 'javascript/auto',
test: /\.mjs$/,
use: [],
},
{ {
test: /\.js$/, test: /\.js$/,
exclude: path => /node_modules|vendor[\\/]assets/.test(path) && !/\.vue\.js/.test(path), exclude: path => /node_modules|vendor[\\/]assets/.test(path) && !/\.vue\.js/.test(path),
...@@ -121,6 +126,11 @@ module.exports = { ...@@ -121,6 +126,11 @@ module.exports = {
].join('|'), ].join('|'),
}, },
}, },
{
test: /\.(graphql|gql)$/,
exclude: /node_modules/,
loader: 'graphql-tag/loader',
},
{ {
test: /\.svg$/, test: /\.svg$/,
loader: 'raw-loader', 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 "" ...@@ -1379,6 +1379,9 @@ msgstr ""
msgid "Close" msgid "Close"
msgstr "" msgstr ""
msgid "Closed"
msgstr ""
msgid "ClusterIntegration|%{appList} was successfully installed on your Kubernetes cluster" msgid "ClusterIntegration|%{appList} was successfully installed on your Kubernetes cluster"
msgstr "" msgstr ""
...@@ -4467,6 +4470,9 @@ msgstr "" ...@@ -4467,6 +4470,9 @@ msgstr ""
msgid "Open source software to collaborate on code" msgid "Open source software to collaborate on code"
msgstr "" msgstr ""
msgid "Opened"
msgstr ""
msgid "OpenedNDaysAgo|Opened" msgid "OpenedNDaysAgo|Opened"
msgstr "" msgstr ""
...@@ -5856,6 +5862,9 @@ msgstr "" ...@@ -5856,6 +5862,9 @@ msgstr ""
msgid "Sign-up restrictions" msgid "Sign-up restrictions"
msgstr "" msgstr ""
msgid "Similar issues"
msgstr ""
msgid "Size and domain settings for static websites" msgid "Size and domain settings for static websites"
msgstr "" msgstr ""
...@@ -6392,6 +6401,9 @@ msgstr "" ...@@ -6392,6 +6401,9 @@ msgstr ""
msgid "There was an error when unsubscribing from this label." msgid "There was an error when unsubscribing from this label."
msgstr "" 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}." msgid "They can be managed using the %{link}."
msgstr "" msgstr ""
...@@ -7840,6 +7852,9 @@ msgstr "" ...@@ -7840,6 +7852,9 @@ msgstr ""
msgid "this document" msgid "this document"
msgstr "" msgstr ""
msgid "updated"
msgstr ""
msgid "username" msgid "username"
msgstr "" msgstr ""
......
...@@ -26,6 +26,8 @@ ...@@ -26,6 +26,8 @@
"@babel/preset-env": "^7.1.0", "@babel/preset-env": "^7.1.0",
"@gitlab/svgs": "^1.38.0", "@gitlab/svgs": "^1.38.0",
"@gitlab/ui": "^1.11.0", "@gitlab/ui": "^1.11.0",
"apollo-boost": "^0.1.20",
"apollo-client": "^2.4.5",
"autosize": "^4.0.0", "autosize": "^4.0.0",
"axios": "^0.17.1", "axios": "^0.17.1",
"babel-loader": "^8.0.4", "babel-loader": "^8.0.4",
...@@ -60,6 +62,7 @@ ...@@ -60,6 +62,7 @@
"formdata-polyfill": "^3.0.11", "formdata-polyfill": "^3.0.11",
"fuzzaldrin-plus": "^0.5.0", "fuzzaldrin-plus": "^0.5.0",
"glob": "^7.1.2", "glob": "^7.1.2",
"graphql": "^14.0.2",
"imports-loader": "^0.8.0", "imports-loader": "^0.8.0",
"jed": "^1.1.1", "jed": "^1.1.1",
"jquery": "^3.2.1", "jquery": "^3.2.1",
...@@ -97,6 +100,7 @@ ...@@ -97,6 +100,7 @@
"url-loader": "^1.1.1", "url-loader": "^1.1.1",
"visibilityjs": "^1.2.4", "visibilityjs": "^1.2.4",
"vue": "^2.5.17", "vue": "^2.5.17",
"vue-apollo": "^3.0.0-beta.25",
"vue-loader": "^15.4.2", "vue-loader": "^15.4.2",
"vue-resource": "^1.5.0", "vue-resource": "^1.5.0",
"vue-router": "^3.0.1", "vue-router": "^3.0.1",
...@@ -127,6 +131,7 @@ ...@@ -127,6 +131,7 @@
"eslint-plugin-jasmine": "^2.10.1", "eslint-plugin-jasmine": "^2.10.1",
"gettext-extractor": "^3.3.2", "gettext-extractor": "^3.3.2",
"gettext-extractor-vue": "^4.0.1", "gettext-extractor-vue": "^4.0.1",
"graphql-tag": "^2.10.0",
"istanbul": "^0.4.5", "istanbul": "^0.4.5",
"jasmine-core": "^2.9.0", "jasmine-core": "^2.9.0",
"jasmine-diff": "^0.1.3", "jasmine-diff": "^0.1.3",
......
...@@ -682,6 +682,18 @@ describe 'Issues' do ...@@ -682,6 +682,18 @@ describe 'Issues' do
expect(find('.js-issuable-selector .dropdown-toggle-text')).to have_content('bug') expect(find('.js-issuable-selector .dropdown-toggle-text')).to have_content('bug')
end end
end end
context 'suggestions', :js do
it 'displays list of related issues' do
create(:issue, project: project, title: 'test issue')
visit new_project_issue_path(project)
fill_in 'issue_title', with: issue.title
expect(page).to have_selector('.suggestion-item', count: 1)
end
end
end end
describe 'new issue by email' do describe 'new issue by email' do
......
require 'spec_helper'
describe Resolvers::IssuesResolver do
include GraphqlHelpers
let(:current_user) { create(:user) }
set(:project) { create(:project) }
set(:issue) { create(:issue, project: project) }
set(:issue2) { create(:issue, project: project, title: 'foo') }
before do
project.add_developer(current_user)
end
describe '#resolve' do
it 'finds all issues' do
expect(resolve_issues).to contain_exactly(issue, issue2)
end
it 'searches issues' do
expect(resolve_issues(search: 'foo')).to contain_exactly(issue2)
end
it 'sort issues' do
expect(resolve_issues(sort: 'created_desc')).to eq [issue2, issue]
end
it 'returns issues user can see' do
project.add_guest(current_user)
create(:issue, confidential: true)
expect(resolve_issues).to contain_exactly(issue, issue2)
end
end
def resolve_issues(args = {}, context = { current_user: current_user })
resolve(described_class, obj: project, args: args, ctx: context)
end
end
require 'spec_helper'
describe GitlabSchema.types['Issue'] do
it { expect(described_class).to expose_permissions_using(Types::PermissionTypes::Issue) }
it { expect(described_class.graphql_name).to eq('Issue') }
end
require 'spec_helper'
describe Types::PermissionTypes::Issue do
it do
expected_permissions = [
:read_issue, :admin_issue, :update_issue,
:create_note, :reopen_issue
]
expect(described_class).to have_graphql_fields(expected_permissions)
end
end
...@@ -14,5 +14,9 @@ describe GitlabSchema.types['Project'] do ...@@ -14,5 +14,9 @@ describe GitlabSchema.types['Project'] do
end end
end end
describe 'nested issues' do
it { expect(described_class).to have_graphql_field(:issues) }
end
it { is_expected.to have_graphql_field(:pipelines) } it { is_expected.to have_graphql_field(:pipelines) }
end end
import { shallowMount } from '@vue/test-utils';
import App from '~/issuable_suggestions/components/app.vue';
import Suggestion from '~/issuable_suggestions/components/item.vue';
describe('Issuable suggestions app component', () => {
let vm;
function createComponent(search = 'search') {
vm = shallowMount(App, {
propsData: {
search,
projectPath: 'project',
},
});
}
afterEach(() => {
vm.destroy();
});
it('does not render with empty search', () => {
createComponent('');
expect(vm.isVisible()).toBe(false);
});
describe('with data', () => {
let data;
beforeEach(() => {
data = { issues: [{ id: 1 }, { id: 2 }] };
});
it('renders component', () => {
createComponent();
vm.setData(data);
expect(vm.isEmpty()).toBe(false);
});
it('does not render with empty search', () => {
createComponent('');
vm.setData(data);
expect(vm.isVisible()).toBe(false);
});
it('does not render when loading', () => {
createComponent();
vm.setData({
...data,
loading: 1,
});
expect(vm.isVisible()).toBe(false);
});
it('does not render with empty issues data', () => {
createComponent();
vm.setData({ issues: [] });
expect(vm.isVisible()).toBe(false);
});
it('renders list of issues', () => {
createComponent();
vm.setData(data);
expect(vm.findAll(Suggestion).length).toBe(2);
});
it('adds margin class to first item', () => {
createComponent();
vm.setData(data);
expect(
vm
.findAll('li')
.at(0)
.is('.append-bottom-default'),
).toBe(true);
});
it('does not add margin class to last item', () => {
createComponent();
vm.setData(data);
expect(
vm
.findAll('li')
.at(1)
.is('.append-bottom-default'),
).toBe(false);
});
});
});
import { shallowMount } from '@vue/test-utils';
import { GlTooltip, GlLink } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
import Suggestion from '~/issuable_suggestions/components/item.vue';
import mockData from '../mock_data';
describe('Issuable suggestions suggestion component', () => {
let vm;
function createComponent(suggestion = {}) {
vm = shallowMount(Suggestion, {
propsData: {
suggestion: {
...mockData(),
...suggestion,
},
},
});
}
afterEach(() => {
vm.destroy();
});
it('renders title', () => {
createComponent();
expect(vm.text()).toContain('Test issue');
});
it('renders issue link', () => {
createComponent();
const link = vm.find(GlLink);
expect(link.attributes('href')).toBe(`${gl.TEST_HOST}/test/issue/1`);
});
it('renders IID', () => {
createComponent();
expect(vm.text()).toContain('#1');
});
describe('opened state', () => {
it('renders icon', () => {
createComponent();
const icon = vm.find(Icon);
expect(icon.props('name')).toBe('issue-open-m');
});
it('renders created timeago', () => {
createComponent({
closedAt: '',
});
const tooltip = vm.find(GlTooltip);
expect(tooltip.find('.d-block').text()).toContain('Opened');
expect(tooltip.text()).toContain('3 days ago');
});
});
describe('closed state', () => {
it('renders icon', () => {
createComponent({
state: 'closed',
});
const icon = vm.find(Icon);
expect(icon.props('name')).toBe('issue-close');
});
it('renders closed timeago', () => {
createComponent();
const tooltip = vm.find(GlTooltip);
expect(tooltip.find('.d-block').text()).toContain('Opened');
expect(tooltip.text()).toContain('1 day ago');
});
});
describe('author', () => {
it('renders author info', () => {
createComponent();
const link = vm.findAll(GlLink).at(1);
expect(link.text()).toContain('Author Name');
expect(link.text()).toContain('@author.username');
});
it('renders author image', () => {
createComponent();
const image = vm.find(UserAvatarImage);
expect(image.props('imgSrc')).toBe(`${gl.TEST_HOST}/avatar`);
});
});
describe('counts', () => {
it('renders upvotes count', () => {
createComponent();
const count = vm.findAll('.suggestion-counts span').at(0);
expect(count.text()).toContain('1');
expect(count.find(Icon).props('name')).toBe('thumb-up');
});
it('renders notes count', () => {
createComponent();
const count = vm.findAll('.suggestion-counts span').at(1);
expect(count.text()).toContain('2');
expect(count.find(Icon).props('name')).toBe('comment');
});
});
describe('confidential', () => {
it('renders confidential icon', () => {
createComponent({
confidential: true,
});
const icon = vm.find(Icon);
expect(icon.props('name')).toBe('eye-slash');
expect(icon.attributes('data-original-title')).toBe('Confidential');
});
});
});
function getDate(daysMinus) {
const today = new Date();
today.setDate(today.getDate() - daysMinus);
return today.toISOString();
}
export default () => ({
id: 1,
iid: 1,
state: 'opened',
upvotes: 1,
userNotesCount: 2,
closedAt: getDate(1),
createdAt: getDate(3),
updatedAt: getDate(2),
confidential: false,
webUrl: `${gl.TEST_HOST}/test/issue/1`,
title: 'Test issue',
author: {
avatarUrl: `${gl.TEST_HOST}/avatar`,
name: 'Author Name',
username: 'author.username',
webUrl: `${gl.TEST_HOST}/author`,
},
});
require 'spec_helper'
describe Gitlab::Graphql::Loaders::BatchModelLoader do
describe '#find' do
let(:issue) { create(:issue) }
let(:user) { create(:user) }
it 'finds a model by id' do
issue_result = described_class.new(Issue, issue.id).find
user_result = described_class.new(User, user.id).find
expect(issue_result.__sync).to eq(issue)
expect(user_result.__sync).to eq(user)
end
it 'only queries once per model' do
other_user = create(:user)
user
issue
expect do
[described_class.new(User, other_user.id).find,
described_class.new(User, user.id).find,
described_class.new(Issue, issue.id).find].map(&:__sync)
end.not_to exceed_query_limit(2)
end
end
end
require 'spec_helper'
describe 'getting an issue list for a project' do
include GraphqlHelpers
let(:project) { create(:project, :repository, :public) }
let(:current_user) { create(:user) }
let(:issues_data) { graphql_data['project']['issues']['edges'] }
let!(:issues) do
create(:issue, project: project, discussion_locked: true)
create(:issue, project: project)
end
let(:fields) do
<<~QUERY
edges {
node {
#{all_graphql_fields_for('issues'.classify)}
}
}
QUERY
end
let(:query) do
graphql_query_for(
'project',
{ 'fullPath' => project.full_path },
query_graphql_field('issues', {}, fields)
)
end
it_behaves_like 'a working graphql query' do
before do
post_graphql(query, current_user: current_user)
end
end
it 'includes a web_url' do
post_graphql(query, current_user: current_user)
expect(issues_data[0]['node']['webUrl']).to be_present
end
it 'includes discussion locked' do
post_graphql(query, current_user: current_user)
expect(issues_data[0]['node']['discussionLocked']).to eq false
expect(issues_data[1]['node']['discussionLocked']).to eq true
end
context 'when the user does not have access to the issue' do
it 'returns nil' do
project.project_feature.update!(issues_access_level: ProjectFeature::PRIVATE)
post_graphql(query)
expect(issues_data).to eq []
end
end
end
This diff is collapsed.
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment