Commit a99f342b authored by Filipa Lacerda's avatar Filipa Lacerda

Merge branch 'issuable-suggestions' into 'master'

Suggest issues when typing title

Closes #22071

See merge request gitlab-org/gitlab-ce!22866
parents d0b529d1 50e21a89
<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 ""
......@@ -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 ""
......
......@@ -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
......
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
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
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