Merge branch 'issuable-suggestions' into 'master'

Suggest issues when typing title

Closes #22071

See merge request gitlab-org/gitlab-ce!22866
This commit is contained in:
Filipa Lacerda 2018-11-27 17:58:27 +00:00
commit a99f342b42
36 changed files with 1185 additions and 1 deletions

View file

@ -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>

View file

@ -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 }} &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>

View file

@ -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,
},
});
},
});
}

View file

@ -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
}
}
}
}
}
}

View file

@ -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,
},
});

View file

@ -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();
}
};

View file

@ -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;
}
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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,

10
app/graphql/types/sort.rb Normal file
View file

@ -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

View file

@ -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

View file

@ -0,0 +1,5 @@
# frozen_string_literal: true
class MilestonePolicy < BasePolicy
delegate { @subject.project }
end

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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',

View file

@ -0,0 +1,29 @@
# 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

View file

@ -1379,6 +1379,9 @@ msgstr ""
msgid "Close"
msgstr ""
msgid "Closed"
msgstr ""
msgid "ClusterIntegration|%{appList} was successfully installed on your Kubernetes cluster"
msgstr ""
@ -4473,6 +4476,9 @@ msgstr ""
msgid "Open source software to collaborate on code"
msgstr ""
msgid "Opened"
msgstr ""
msgid "OpenedNDaysAgo|Opened"
msgstr ""
@ -5862,6 +5868,9 @@ msgstr ""
msgid "Sign-up restrictions"
msgstr ""
msgid "Similar issues"
msgstr ""
msgid "Size and domain settings for static websites"
msgstr ""
@ -6398,6 +6407,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 ""
@ -7846,6 +7858,9 @@ msgstr ""
msgid "this document"
msgstr ""
msgid "updated"
msgstr ""
msgid "username"
msgstr ""

View file

@ -26,6 +26,8 @@
"@babel/preset-env": "^7.1.0",
"@gitlab/svgs": "^1.38.0",
"@gitlab/ui": "^1.11.0",
"apollo-boost": "^0.1.20",
"apollo-client": "^2.4.5",
"autosize": "^4.0.0",
"axios": "^0.17.1",
"babel-loader": "^8.0.4",
@ -60,6 +62,7 @@
"formdata-polyfill": "^3.0.11",
"fuzzaldrin-plus": "^0.5.0",
"glob": "^7.1.2",
"graphql": "^14.0.2",
"imports-loader": "^0.8.0",
"jed": "^1.1.1",
"jquery": "^3.2.1",
@ -97,6 +100,7 @@
"url-loader": "^1.1.1",
"visibilityjs": "^1.2.4",
"vue": "^2.5.17",
"vue-apollo": "^3.0.0-beta.25",
"vue-loader": "^15.4.2",
"vue-resource": "^1.5.0",
"vue-router": "^3.0.1",
@ -127,6 +131,7 @@
"eslint-plugin-jasmine": "^2.10.1",
"gettext-extractor": "^3.3.2",
"gettext-extractor-vue": "^4.0.1",
"graphql-tag": "^2.10.0",
"istanbul": "^0.4.5",
"jasmine-core": "^2.9.0",
"jasmine-diff": "^0.1.3",

View file

@ -682,6 +682,18 @@ describe 'Issues' do
expect(find('.js-issuable-selector .dropdown-toggle-text')).to have_content('bug')
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
describe 'new issue by email' do

View file

@ -0,0 +1,40 @@
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

View file

@ -0,0 +1,7 @@
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

View file

@ -0,0 +1,12 @@
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

View file

@ -14,5 +14,9 @@ describe GitlabSchema.types['Project'] do
end
end
describe 'nested issues' do
it { expect(described_class).to have_graphql_field(:issues) }
end
it { is_expected.to have_graphql_field(:pipelines) }
end

View file

@ -0,0 +1,96 @@
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);
});
});
});

View file

@ -0,0 +1,139 @@
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');
});
});
});

View file

@ -0,0 +1,26 @@
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`,
},
});

View file

@ -0,0 +1,28 @@
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

View file

@ -0,0 +1,59 @@
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

173
yarn.lock
View file

@ -654,6 +654,11 @@
resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.7.0.tgz#9a06f4f137ee84d7df0460c1fdb1135ffa6c50fd"
integrity sha512-ONhaKPIufzzrlNbqtWFFd+jlnemX6lJAgq9ZeiZtS7I1PIf/la7CW4m83rTXRnVnsMbW2k56pGYu7AUFJD9Pow==
"@types/async@2.0.50":
version "2.0.50"
resolved "https://registry.yarnpkg.com/@types/async/-/async-2.0.50.tgz#117540e026d64e1846093abbd5adc7e27fda7bcb"
integrity sha512-VMhZMMQgV1zsR+lX/0IBfAk+8Eb7dPVMWiQGFAt3qjo5x7Ml6b77jUo0e1C3ToD+XRDXqtrfw+6AB0uUsPEr3Q==
"@types/events@*":
version "1.2.0"
resolved "https://registry.yarnpkg.com/@types/events/-/events-1.2.0.tgz#81a6731ce4df43619e5c8c945383b3e62a89ea86"
@ -693,6 +698,11 @@
resolved "https://registry.yarnpkg.com/@types/semver/-/semver-5.5.0.tgz#146c2a29ee7d3bae4bf2fcb274636e264c813c45"
integrity sha512-41qEJgBH/TWgo5NFSvBCJ1qkoi3Q6ONSF2avrHq1LVEZfYpdHmj0y9SuTK+u9ZhG1sYQKBL1AWXKyLWP4RaUoQ==
"@types/zen-observable@^0.8.0":
version "0.8.0"
resolved "https://registry.yarnpkg.com/@types/zen-observable/-/zen-observable-0.8.0.tgz#8b63ab7f1aa5321248aad5ac890a485656dcea4d"
integrity sha512-te5lMAWii1uEJ4FwLjzdlbw3+n0FZNOvFXHxQDKeT0dilh7HOzdMzV2TrJVUzq8ep7J4Na8OUYPRLSQkJHAlrg==
"@vue/component-compiler-utils@^2.0.0":
version "2.2.0"
resolved "https://registry.yarnpkg.com/@vue/component-compiler-utils/-/component-compiler-utils-2.2.0.tgz#bbbb7ed38a9a8a7c93abe7ef2e54a90a04b631b4"
@ -991,6 +1001,103 @@ anymatch@^2.0.0:
micromatch "^3.1.4"
normalize-path "^2.1.1"
apollo-boost@^0.1.20:
version "0.1.20"
resolved "https://registry.yarnpkg.com/apollo-boost/-/apollo-boost-0.1.20.tgz#cc3e418ebd2bea857656685d32a7a20443493363"
integrity sha512-n2MiEY5IGpD/cy0RH+pM9vbmobM/JZ5qz38XQAUA41FxxMPlLFQxf0IUMm0tijLOJvJJBub3pDt+Of4TVPBCqA==
dependencies:
apollo-cache "^1.1.20"
apollo-cache-inmemory "^1.3.9"
apollo-client "^2.4.5"
apollo-link "^1.0.6"
apollo-link-error "^1.0.3"
apollo-link-http "^1.3.1"
apollo-link-state "^0.4.0"
graphql-tag "^2.4.2"
apollo-cache-inmemory@^1.3.9:
version "1.3.9"
resolved "https://registry.yarnpkg.com/apollo-cache-inmemory/-/apollo-cache-inmemory-1.3.9.tgz#10738ba6a04faaeeb0da21bbcc1f7c0b5902910c"
integrity sha512-Q2k84p/OqIuMUyeWGc6XbVXXZu0erYOO+wTx9p+CnQUspnNvf7zmvFNgFnmudXzfuG1m1CSzePk6fC/M1ehOqQ==
dependencies:
apollo-cache "^1.1.20"
apollo-utilities "^1.0.25"
optimism "^0.6.6"
apollo-cache@1.1.20, apollo-cache@^1.1.20:
version "1.1.20"
resolved "https://registry.yarnpkg.com/apollo-cache/-/apollo-cache-1.1.20.tgz#6152cc4baf6a63e376efee79f75de4f5c84bf90e"
integrity sha512-+Du0/4kUSuf5PjPx0+pvgMGV12ezbHA8/hubYuqRQoy/4AWb4faa61CgJNI6cKz2mhDd9m94VTNKTX11NntwkQ==
dependencies:
apollo-utilities "^1.0.25"
apollo-client@^2.4.5:
version "2.4.5"
resolved "https://registry.yarnpkg.com/apollo-client/-/apollo-client-2.4.5.tgz#545beda1ef60814943b5622f0feabc9f29ee9822"
integrity sha512-nUm06EGa4TP/IY68OzmC3lTD32TqkjLOQdb69uYo+lHl8NnwebtrAw3qFtsQtTEz6ueBp/Z/HasNZng4jwafVQ==
dependencies:
"@types/zen-observable" "^0.8.0"
apollo-cache "1.1.20"
apollo-link "^1.0.0"
apollo-link-dedup "^1.0.0"
apollo-utilities "1.0.25"
symbol-observable "^1.0.2"
zen-observable "^0.8.0"
optionalDependencies:
"@types/async" "2.0.50"
apollo-link-dedup@^1.0.0:
version "1.0.10"
resolved "https://registry.yarnpkg.com/apollo-link-dedup/-/apollo-link-dedup-1.0.10.tgz#7b94589fe7f969777efd18a129043c78430800ae"
integrity sha512-tpUI9lMZsidxdNygSY1FxflXEkUZnvKRkMUsXXuQUNoSLeNtEvUX7QtKRAl4k9ubLl8JKKc9X3L3onAFeGTK8w==
dependencies:
apollo-link "^1.2.3"
apollo-link-error@^1.0.3:
version "1.1.1"
resolved "https://registry.yarnpkg.com/apollo-link-error/-/apollo-link-error-1.1.1.tgz#69d7124d4dc11ce60f505c940f05d4f1aa0945fb"
integrity sha512-/yPcaQWcBdB94vpJ4FsiCJt1dAGGRm+6Tsj3wKwP+72taBH+UsGRQQZk7U/1cpZwl1yqhHZn+ZNhVOebpPcIlA==
dependencies:
apollo-link "^1.2.3"
apollo-link-http-common@^0.2.5:
version "0.2.5"
resolved "https://registry.yarnpkg.com/apollo-link-http-common/-/apollo-link-http-common-0.2.5.tgz#d094beb7971523203359bf830bfbfa7b4e7c30ed"
integrity sha512-6FV1wr5AqAyJ64Em1dq5hhGgiyxZE383VJQmhIoDVc3MyNcFL92TkhxREOs4rnH2a9X2iJMko7nodHSGLC6d8w==
dependencies:
apollo-link "^1.2.3"
apollo-link-http@^1.3.1:
version "1.5.5"
resolved "https://registry.yarnpkg.com/apollo-link-http/-/apollo-link-http-1.5.5.tgz#7dbe851821771ad67fa29e3900c57f38cbd80da8"
integrity sha512-C5N6N/mRwmepvtzO27dgMEU3MMtRKSqcljBkYNZmWwH11BxkUQ5imBLPM3V4QJXNE7NFuAQAB5PeUd4ligivTQ==
dependencies:
apollo-link "^1.2.3"
apollo-link-http-common "^0.2.5"
apollo-link-state@^0.4.0:
version "0.4.2"
resolved "https://registry.yarnpkg.com/apollo-link-state/-/apollo-link-state-0.4.2.tgz#ac00e9be9b0ca89eae0be6ba31fe904b80bbe2e8"
integrity sha512-xMPcAfuiPVYXaLwC6oJFIZrKgV3GmdO31Ag2eufRoXpvT0AfJZjdaPB4450Nu9TslHRePN9A3quxNueILlQxlw==
dependencies:
apollo-utilities "^1.0.8"
graphql-anywhere "^4.1.0-alpha.0"
apollo-link@^1.0.0, apollo-link@^1.0.6, apollo-link@^1.2.3:
version "1.2.3"
resolved "https://registry.yarnpkg.com/apollo-link/-/apollo-link-1.2.3.tgz#9bd8d5fe1d88d31dc91dae9ecc22474d451fb70d"
integrity sha512-iL9yS2OfxYhigme5bpTbmRyC+Htt6tyo2fRMHT3K1XRL/C5IQDDz37OjpPy4ndx7WInSvfSZaaOTKFja9VWqSw==
dependencies:
apollo-utilities "^1.0.0"
zen-observable-ts "^0.8.10"
apollo-utilities@1.0.25, apollo-utilities@^1.0.0, apollo-utilities@^1.0.25, apollo-utilities@^1.0.8:
version "1.0.25"
resolved "https://registry.yarnpkg.com/apollo-utilities/-/apollo-utilities-1.0.25.tgz#899b00f5f990fb451675adf84cb3de82eb6372ea"
integrity sha512-AXvqkhni3Ir1ffm4SA1QzXn8k8I5BBl4PVKEyak734i4jFdp+xgfUyi2VCqF64TJlFTA/B73TRDUvO2D+tKtZg==
dependencies:
fast-json-stable-stringify "^2.0.0"
append-transform@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/append-transform/-/append-transform-1.0.0.tgz#046a52ae582a228bd72f58acfbe2967c678759ab"
@ -3958,6 +4065,25 @@ graphlibrary@^2.2.0:
dependencies:
lodash "^4.17.5"
graphql-anywhere@^4.1.0-alpha.0:
version "4.1.22"
resolved "https://registry.yarnpkg.com/graphql-anywhere/-/graphql-anywhere-4.1.22.tgz#1c831ba3c9e5664a0dd24d10d23a9e9512d92056"
integrity sha512-qm2/1cKM8nfotxDhm4J0r1znVlK0Yge/yEKt26EVVBgpIhvxjXYFALCGbr7cvfDlvzal1iSPpaYa+8YTtjsxQA==
dependencies:
apollo-utilities "^1.0.25"
graphql-tag@^2.10.0, graphql-tag@^2.4.2:
version "2.10.0"
resolved "https://registry.yarnpkg.com/graphql-tag/-/graphql-tag-2.10.0.tgz#87da024be863e357551b2b8700e496ee2d4353ae"
integrity sha512-9FD6cw976TLLf9WYIUPCaaTpniawIjHWZSwIRZSjrfufJamcXbVVYfN2TWvJYbw0Xf2JjYbl1/f2+wDnBVw3/w==
graphql@^14.0.2:
version "14.0.2"
resolved "https://registry.yarnpkg.com/graphql/-/graphql-14.0.2.tgz#7dded337a4c3fd2d075692323384034b357f5650"
integrity sha512-gUC4YYsaiSJT1h40krG3J+USGlwhzNTXSb4IOZljn9ag5Tj+RkoXrWp+Kh7WyE3t1NCfab5kzCuxBIvOMERMXw==
dependencies:
iterall "^1.2.2"
gzip-size@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/gzip-size/-/gzip-size-5.0.0.tgz#a55ecd99222f4c48fd8c01c625ce3b349d0a0e80"
@ -4254,6 +4380,11 @@ immediate@~3.0.5:
resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b"
integrity sha1-nbHb0Pr43m++D13V5Wu2BigN5ps=
immutable-tuple@^0.4.9:
version "0.4.9"
resolved "https://registry.yarnpkg.com/immutable-tuple/-/immutable-tuple-0.4.9.tgz#473ebdd6c169c461913a454bf87ef8f601a20ff0"
integrity sha512-LWbJPZnidF8eczu7XmcnLBsumuyRBkpwIRPCZxlojouhBo5jEBO4toj6n7hMy6IxHU/c+MqDSWkvaTpPlMQcyA==
import-lazy@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/import-lazy/-/import-lazy-2.1.0.tgz#05698e3d45c88e8d7e9d92cb0584e77f096f3e43"
@ -4801,6 +4932,11 @@ isurl@^1.0.0-alpha5:
has-to-string-tag-x "^1.2.0"
is-object "^1.0.1"
iterall@^1.2.2:
version "1.2.2"
resolved "https://registry.yarnpkg.com/iterall/-/iterall-1.2.2.tgz#92d70deb8028e0c39ff3164fdbf4d8b088130cd7"
integrity sha512-yynBb1g+RFUPY64fTrFv7nsjRrENBQJaX2UL+2Szc9REFrSNm1rpSXHGzhmAy7a9uv3vlvgBlXnf9RqmPH1/DA==
jasmine-core@^2.9.0:
version "2.9.0"
resolved "https://registry.yarnpkg.com/jasmine-core/-/jasmine-core-2.9.0.tgz#bfbb56defcd30789adec5a3fbba8504233289c72"
@ -5940,6 +6076,13 @@ opn@^5.1.0:
dependencies:
is-wsl "^1.1.0"
optimism@^0.6.6:
version "0.6.8"
resolved "https://registry.yarnpkg.com/optimism/-/optimism-0.6.8.tgz#0780b546da8cd0a72e5207e0c3706c990c8673a6"
integrity sha512-bN5n1KCxSqwBDnmgDnzMtQTHdL+uea2HYFx1smvtE+w2AMl0Uy31g0aXnP/Nt85OINnMJPRpJyfRQLTCqn5Weg==
dependencies:
immutable-tuple "^0.4.9"
optimist@^0.6.1:
version "0.6.1"
resolved "https://registry.yarnpkg.com/optimist/-/optimist-0.6.1.tgz#da3ea74686fa21a19a111c326e90eb15a0196686"
@ -7586,6 +7729,11 @@ svg4everybody@2.1.9:
resolved "https://registry.yarnpkg.com/svg4everybody/-/svg4everybody-2.1.9.tgz#5bd9f6defc133859a044646d4743fabc28db7e2d"
integrity sha1-W9n23vwTOFmgRGRtR0P6vCjbfi0=
symbol-observable@^1.0.2:
version "1.2.0"
resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804"
integrity sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==
table@^4.0.3:
version "4.0.3"
resolved "http://registry.npmjs.org/table/-/table-4.0.3.tgz#00b5e2b602f1794b9acaf9ca908a76386a7813bc"
@ -7663,6 +7811,11 @@ three@^0.84.0:
resolved "https://registry.yarnpkg.com/three/-/three-0.84.0.tgz#95be85a55a0fa002aa625ed559130957dcffd918"
integrity sha1-lb6FpVoPoAKqYl7VWRMJV9z/2Rg=
throttle-debounce@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/throttle-debounce/-/throttle-debounce-2.0.1.tgz#7307ddd6cd9acadb349132fbf6c18d78c88a5e62"
integrity sha512-Sr6jZBlWShsAaSXKyNXyNicOrJW/KtkDqIEwHt4wYwWA2wa/q67Luhqoujg48V8hTk60wB56tYrJJn6jc2R7VA==
through2@^2.0.0:
version "2.0.3"
resolved "https://registry.yarnpkg.com/through2/-/through2-2.0.3.tgz#0004569b37c7c74ba39c43f3ced78d1ad94140be"
@ -8101,6 +8254,14 @@ void-elements@^2.0.0:
resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-2.0.1.tgz#c066afb582bb1cb4128d60ea92392e94d5e9dbec"
integrity sha1-wGavtYK7HLQSjWDqkjkulNXp2+w=
vue-apollo@^3.0.0-beta.25:
version "3.0.0-beta.25"
resolved "https://registry.yarnpkg.com/vue-apollo/-/vue-apollo-3.0.0-beta.25.tgz#05a9a699b2ba6103639e9bd6c3bb88ca04c4b637"
integrity sha512-M7/l3h0NlFvaZ/s/wrtRiOt3xXMbaNNuteGaCY+U5D0ABrQqvCgy5mayIZHurQxbloluNkbCt18wRKAgJTAuKA==
dependencies:
chalk "^2.4.1"
throttle-debounce "^2.0.0"
vue-eslint-parser@^3.2.1:
version "3.2.2"
resolved "https://registry.yarnpkg.com/vue-eslint-parser/-/vue-eslint-parser-3.2.2.tgz#47c971ee4c39b0ee7d7f5e154cb621beb22f7a34"
@ -8516,3 +8677,15 @@ yeast@0.1.2:
version "0.1.2"
resolved "https://registry.yarnpkg.com/yeast/-/yeast-0.1.2.tgz#008e06d8094320c372dbc2f8ed76a0ca6c8ac419"
integrity sha1-AI4G2AlDIMNy28L47XagymyKxBk=
zen-observable-ts@^0.8.10:
version "0.8.10"
resolved "https://registry.yarnpkg.com/zen-observable-ts/-/zen-observable-ts-0.8.10.tgz#18e2ce1c89fe026e9621fd83cc05168228fce829"
integrity sha512-5vqMtRggU/2GhePC9OU4sYEWOdvmayp2k3gjPf4F0mXwB3CSbbNznfDUvDJx9O2ZTa1EIXdJhPchQveFKwNXPQ==
dependencies:
zen-observable "^0.8.0"
zen-observable@^0.8.0:
version "0.8.11"
resolved "https://registry.yarnpkg.com/zen-observable/-/zen-observable-0.8.11.tgz#d3415885eeeb42ee5abb9821c95bb518fcd6d199"
integrity sha512-N3xXQVr4L61rZvGMpWe8XoCGX8vhU35dPyQ4fm5CY/KDlG0F75un14hjbckPXTDuKUY6V0dqR2giT6xN8Y4GEQ==