Commit 16eb31b1 authored by Jacob Schatz's avatar Jacob Schatz

Merge branch '3551-epic-issues' into 'master'

add related issues component to Epic

Closes #3551

See merge request gitlab-org/gitlab-ee!3302
parents b0c8c98a 805e7678
......@@ -14,7 +14,7 @@ document.addEventListener('DOMContentLoaded', () => {
render: createElement => createElement('related-issues-root', {
props: {
endpoint: relatedIssuesRootElement.dataset.endpoint,
canAddRelatedIssues: convertPermissionToBoolean(
canAdmin: convertPermissionToBoolean(
relatedIssuesRootElement.dataset.canAddRelatedIssues,
),
helpPath: relatedIssuesRootElement.dataset.helpPath,
......
......@@ -43,12 +43,15 @@ export default {
computed: {
inputPlaceholder() {
return 'Paste issue link or <#issue id>';
return `Paste issue link${this.allowAutoComplete ? ' or <#issue id>' : ''}`;
},
isSubmitButtonDisabled() {
return (this.inputValue.length === 0 && this.pendingReferences.length === 0)
|| this.isSubmitting;
},
allowAutoComplete() {
return Object.keys(this.autoCompleteSources).length > 0;
},
},
methods: {
......@@ -86,12 +89,14 @@ export default {
mounted() {
const $input = $(this.$refs.input);
this.gfmAutoComplete = new GfmAutoComplete(this.autoCompleteSources);
this.gfmAutoComplete.setup($input, {
issues: true,
});
$input.on('shown-issues.atwho', this.onAutoCompleteToggled.bind(this, true));
$input.on('hidden-issues.atwho', this.onAutoCompleteToggled.bind(this, false));
if (this.allowAutoComplete) {
this.gfmAutoComplete = new GfmAutoComplete(this.autoCompleteSources);
this.gfmAutoComplete.setup($input, {
issues: true,
});
$input.on('shown-issues.atwho', this.onAutoCompleteToggled.bind(this, true));
$input.on('hidden-issues.atwho', this.onAutoCompleteToggled.bind(this, false));
}
this.$refs.input.focus();
},
......@@ -114,15 +119,22 @@ export default {
role="button"
@click="onInputWrapperClick">
<ul class="add-issuable-form-input-token-list">
<!--
We need to ensure this key changes any time the pendingReferences array is updated
else two consecutive pending ref strings in an array with the same name will collide
and cause odd behavior when one is removed.
-->
<li
:key="reference"
:key="`${pendingReferences.length}-${reference}`"
v-for="(reference, index) in pendingReferences"
class="js-add-issuable-form-token-list-item add-issuable-form-token-list-item">
<issue-token
event-namespace="pendingIssuable"
:id-key="index"
:display-reference="reference"
:can-remove="true" />
:can-remove="true"
:is-condensed="true"
/>
</li>
<li class="add-issuable-form-input-list-item">
<input
......@@ -144,11 +156,10 @@ export default {
class="js-add-issuable-form-add-button btn btn-new pull-left"
:disabled="isSubmitButtonDisabled">
Add
<loadingIcon
<loading-icon
ref="loadingIcon"
v-if="isSubmitting"
:inline="true"
label="Submitting related issues" />
:inline="true" />
</button>
<button
type="button"
......
......@@ -4,7 +4,11 @@ import tooltip from '../../../vue_shared/directives/tooltip';
export default {
name: 'IssueToken',
data() {
return {
removeDisabled: false,
};
},
props: {
idKey: {
type: Number,
......@@ -39,6 +43,11 @@ export default {
required: false,
default: false,
},
isCondensed: {
type: Boolean,
required: false,
default: false,
},
},
directives: {
......@@ -47,11 +56,16 @@ export default {
computed: {
removeButtonLabel() {
return `Remove related issue ${this.displayReference}`;
return `Remove ${this.displayReference}`;
},
hasState() {
return this.state && this.state.length > 0;
},
stateTitle() {
if (this.isCondensed) return '';
return this.isOpen ? 'Open' : 'Closed';
},
isOpen() {
return this.state === 'opened';
},
......@@ -67,6 +81,12 @@ export default {
computedPath() {
return this.path.length ? this.path : null;
},
innerComponentType() {
return this.isCondensed ? 'span' : 'div';
},
issueTitle() {
return this.isCondensed ? this.title : '';
},
},
methods: {
......@@ -77,53 +97,81 @@ export default {
}
eventHub.$emit(`${namespacePrefix}removeRequest`, this.idKey);
this.removeDisabled = true;
},
},
};
</script>
<template>
<div class="issue-token">
<div :class="{
'issue-token': isCondensed,
'flex-row issue-info-container': !isCondensed,
}">
<component
v-tooltip
:is="this.computedLinkElementType"
ref="link"
class="issue-token-link"
:class="{
'issue-token-link': isCondensed,
'issue-main-info': !isCondensed,
}"
:href="computedPath"
:title="title"
data-placement="top">
<span
:title="issueTitle"
data-placement="top"
>
<component
:is="innerComponentType"
v-if="hasTitle"
ref="title"
class="js-issue-token-title"
:class="{
'issue-token-title issue-token-end': isCondensed,
'issue-title block-truncated': !isCondensed,
'issue-token-title-standalone': !canRemove
}">
<span class="issue-token-title-text">
{{ title }}
</span>
</component>
<component
:is="innerComponentType"
ref="reference"
class="issue-token-reference">
:class="{
'issue-token-reference': isCondensed,
'issuable-info': !isCondensed,
}">
<i
ref="stateIcon"
v-if="hasState"
v-tooltip
class="fa"
:class="{
'issue-token-state-icon-open fa-circle-o': isOpen,
'issue-token-state-icon-closed fa-minus': isClosed,
}"
:aria-label="state">
</i>
{{ displayReference }}
</span>
<span
v-if="hasTitle"
ref="title"
class="js-issue-token-title issue-token-title"
:class="{ 'issue-token-title-standalone': !canRemove }">
<span class="issue-token-title-text">
{{ title }}
</span>
</span>
:title="stateTitle"
:aria-label="state"
>
</i>{{ displayReference }}
</component>
</component>
<button
v-if="canRemove"
v-tooltip
ref="removeButton"
type="button"
class="js-issue-token-remove-button issue-token-remove-button"
class="js-issue-token-remove-button"
:class="{
'issue-token-remove-button': isCondensed,
'btn btn-default': !isCondensed
}"
:title="removeButtonLabel"
:aria-label="removeButtonLabel"
@click="onRemoveRequest">
:disabled="removeDisabled"
@click="onRemoveRequest"
>
<i
class="fa fa-times"
aria-hidden="true">
......
......@@ -24,7 +24,7 @@ export default {
required: false,
default: () => [],
},
canAddRelatedIssues: {
canAdmin: {
type: Boolean,
required: false,
default: false,
......@@ -54,6 +54,11 @@ export default {
required: false,
default: () => ({}),
},
title: {
type: String,
required: false,
default: 'Related issues',
},
},
directives: {
......@@ -100,7 +105,7 @@ export default {
class="panel-heading"
:class="{ 'panel-empty-heading': !this.hasBody }">
<h3 class="panel-title">
Related issues
{{ title }}
<a
v-if="hasHelpPath"
:href="helpPath">
......@@ -112,11 +117,11 @@ export default {
<div class="js-related-issues-header-issue-count related-issues-header-issue-count issue-count-badge">
<span
class="issue-count-badge-count"
:class="{ 'has-btn': this.canAddRelatedIssues }">
:class="{ 'has-btn': this.canAdmin }">
{{ badgeLabel }}
</span>
<button
v-if="canAddRelatedIssues"
v-if="canAdmin"
ref="issueCountBadgeAddButton"
type="button"
class="js-issue-count-badge-add-button issue-count-badge-add-button btn btn-sm btn-default"
......@@ -156,11 +161,11 @@ export default {
label="Fetching related issues" />
</div>
<ul
class="related-issues-token-list">
class="flex-list content-list issuable-list">
<li
:key="issue.id"
v-for="issue in relatedIssues"
class="js-related-issues-token-list-item related-issues-token-list-item">
class="js-related-issues-token-list-item">
<issue-token
event-namespace="relatedIssue"
:id-key="issue.id"
......@@ -168,7 +173,8 @@ export default {
:title="issue.title"
:path="issue.path"
:state="issue.state"
:can-remove="true" />
:can-remove="canAdmin"
/>
</li>
</ul>
</div>
......
......@@ -40,7 +40,7 @@ export default {
type: String,
required: true,
},
canAddRelatedIssues: {
canAdmin: {
type: Boolean,
required: false,
default: false,
......@@ -50,6 +50,16 @@ export default {
required: false,
default: '',
},
title: {
type: String,
required: false,
default: 'Related issues',
},
allowAutoComplete: {
type: Boolean,
required: false,
default: true,
},
},
data() {
......@@ -70,6 +80,7 @@ export default {
computed: {
autoCompleteSources() {
if (!this.allowAutoComplete) return {};
return gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources;
},
},
......@@ -86,13 +97,11 @@ export default {
})
.catch((res) => {
if (res && res.status !== 404) {
// eslint-disable-next-line no-new
new Flash('An error occurred while removing related issues.');
Flash('An error occurred while removing issues.');
}
});
} else {
// eslint-disable-next-line no-new
new Flash('We could not determine the path to remove the related issue');
Flash('We could not determine the path to remove the issue');
}
},
onToggleAddRelatedIssuesForm() {
......@@ -119,8 +128,11 @@ export default {
})
.catch((res) => {
this.isSubmitting = false;
// eslint-disable-next-line no-new
new Flash(res.data.message || 'We can\'t find an issue that matches what you are looking for.');
let errorMessage = 'We can\'t find an issue that matches what you are looking for.';
if (res.data && res.data.message) {
errorMessage = res.data.message;
}
Flash(errorMessage);
});
}
},
......@@ -137,7 +149,11 @@ export default {
this.store.setRelatedIssues(issues);
this.isFetching = false;
})
.catch(() => new Flash('An error occurred while fetching related issues.'));
.catch(() => {
this.store.setRelatedIssues([]);
this.isFetching = false;
Flash('An error occurred while fetching issues.');
});
},
onInput(newValue, caretPos) {
......@@ -211,9 +227,11 @@ export default {
:is-fetching="isFetching"
:is-submitting="isSubmitting"
:related-issues="state.relatedIssues"
:can-add-related-issues="canAddRelatedIssues"
:can-admin="canAdmin"
:pending-references="state.pendingReferences"
:is-form-visible="isFormVisible"
:input-value="inputValue"
:auto-complete-sources="autoCompleteSources" />
:auto-complete-sources="autoCompleteSources"
:title="title"
/>
</template>
......@@ -8,7 +8,7 @@ class RelatedIssuesStore {
};
}
setRelatedIssues(issues) {
setRelatedIssues(issues = []) {
this.state.relatedIssues = issues;
}
......
......@@ -697,6 +697,7 @@
.issue-main-info {
flex: 1 auto;
margin-right: 10px;
min-width: 0;
}
.issuable-meta {
......
@import "./issues/issue_count_badge";
@import "./issues/related_issues";
.issues-list {
.issue {
......
......@@ -18,7 +18,7 @@ $token_spacing_bottom: 0.5em;
}
.related-issues-token-body {
padding-bottom: calc(#{$gl-padding} - #{$token_spacing_bottom});
padding: 0;
transition-property: max-height, padding, opacity;
transition-duration: $general-hover-transition-duration;
transition-timing-function: $general-hover-transition-curve;
......@@ -30,6 +30,18 @@ $token_spacing_bottom: 0.5em;
padding-bottom: 0;
opacity: 0;
}
li .issue-info-container {
padding-left: $gl-padding;
}
a.issue-main-info:hover {
text-decoration: none;
.issue-token-title-text {
text-decoration: underline;
}
}
}
.related-issues-loading-icon {
......@@ -50,3 +62,7 @@ $token_spacing_bottom: 0.5em;
margin-bottom: $token_spacing_bottom;
margin-right: 5px;
}
.issue-token-end {
order: 1;
}
......@@ -215,6 +215,7 @@ module IssuablesHelper
endpoint: issuable_path(issuable),
canUpdate: can?(current_user, :"update_#{issuable.to_ability_name}", issuable),
canDestroy: can?(current_user, :"destroy_#{issuable.to_ability_name}", issuable),
canAdmin: can?(current_user, :"admin_#{issuable.to_ability_name}", issuable),
issuableRef: issuable.to_reference,
markdownPreviewPath: preview_markdown_path(parent),
markdownDocsPath: help_page_path('user/markdown'),
......@@ -228,6 +229,7 @@ module IssuablesHelper
if parent.is_a?(Group)
data[:groupPath] = parent.path
data[:issueLinksEndpoint] = group_epic_issues_path(parent, issuable)
else
data.merge!(projectPath: ref_project.path, projectNamespace: ref_project.namespace.full_path)
end
......
......@@ -38,6 +38,9 @@ class Issue < ActiveRecord::Base
has_many :issue_assignees
has_many :assignees, class_name: "User", through: :issue_assignees
has_one :epic_issue
has_one :epic, through: :epic_issue
validates :project, presence: true
scope :in_projects, ->(project_ids) { where(project_id: project_ids) }
......
......@@ -78,6 +78,8 @@ constraints(GroupUrlConstrainer.new) do
member do
get :realtime_changes
end
resources :epic_issues, only: [:index, :create, :destroy], as: 'issues', path: 'issues'
end
legacy_ee_group_boards_redirect = redirect do |params, request|
......
class CreateEpicIssuesTable < ActiveRecord::Migration
DOWNTIME = false
disable_ddl_transaction!
def change
create_table :epic_issues do |t|
t.references :epic, null: false, index: true, foreign_key: { on_delete: :cascade }
t.references :issue, null: false, index: { unique: true }, foreign_key: { on_delete: :cascade }
end
end
end
......@@ -732,6 +732,14 @@ ActiveRecord::Schema.define(version: 20171107144726) do
add_index "environments", ["project_id", "name"], name: "index_environments_on_project_id_and_name", unique: true, using: :btree
add_index "environments", ["project_id", "slug"], name: "index_environments_on_project_id_and_slug", unique: true, using: :btree
create_table "epic_issues", force: :cascade do |t|
t.integer "epic_id", null: false
t.integer "issue_id", null: false
end
add_index "epic_issues", ["epic_id"], name: "index_epic_issues_on_epic_id", using: :btree
add_index "epic_issues", ["issue_id"], name: "index_epic_issues_on_issue_id", unique: true, using: :btree
create_table "epic_metrics", force: :cascade do |t|
t.integer "epic_id", null: false
t.datetime_with_timezone "created_at", null: false
......@@ -2393,6 +2401,8 @@ ActiveRecord::Schema.define(version: 20171107144726) do
add_foreign_key "deploy_keys_projects", "projects", name: "fk_58a901ca7e", on_delete: :cascade
add_foreign_key "deployments", "projects", name: "fk_b9a3851b82", on_delete: :cascade
add_foreign_key "environments", "projects", name: "fk_d1c8c1da6a", on_delete: :cascade
add_foreign_key "epic_issues", "epics", on_delete: :cascade
add_foreign_key "epic_issues", "issues", on_delete: :cascade
add_foreign_key "epic_metrics", "epics", on_delete: :cascade
add_foreign_key "epics", "milestones", on_delete: :nullify
add_foreign_key "epics", "namespaces", column: "group_id", name: "fk_f081aa4489", on_delete: :cascade
......
<script>
import issuableApp from '~/issue_show/components/app.vue';
import relatedIssuesRoot from '~/issuable/related_issues/components/related_issues_root.vue';
import issuableAppEventHub from '~/issue_show/event_hub';
import epicHeader from './epic_header.vue';
import epicSidebar from '../../sidebar/components/sidebar_app.vue';
......@@ -19,6 +20,10 @@
required: true,
type: Boolean,
},
canAdmin: {
required: true,
type: Boolean,
},
markdownPreviewPath: {
type: String,
required: true,
......@@ -57,6 +62,10 @@
type: Object,
required: true,
},
issueLinksEndpoint: {
type: String,
required: true,
},
startDate: {
type: String,
required: false,
......@@ -78,6 +87,7 @@
epicHeader,
epicSidebar,
issuableApp,
relatedIssuesRoot,
},
methods: {
deleteEpic() {
......@@ -131,6 +141,12 @@
:initial-start-date="startDate"
:initial-end-date="endDate"
/>
<related-issues-root
:endpoint="issueLinksEndpoint"
:can-admin="canAdmin"
:allow-auto-complete="false"
title="Issues"
/>
</div>
</div>
</template>
module IssuableLinks
def index
render json: issues
end
def create
result = create_service.execute
render json: { message: result[:message], issues: issues }, status: result[:http_status]
end
def destroy
result = destroy_service.execute
render json: { issues: issues }, status: result[:http_status]
end
private
def create_params
params.slice(:issue_references)
end
def create_service
raise NotImplementedError
end
def destroy_service
raise NotImplementedError
end
end
class Groups::EpicIssuesController < Groups::EpicsController
include IssuableLinks
skip_before_action :authorize_destroy_issuable!
before_action :authorize_admin_epic!, only: [:create, :destroy]
before_action :authorize_issue_link_association!, only: :destroy
private
def create_service
EpicIssues::CreateService.new(epic, current_user, create_params)
end
def destroy_service
EpicIssues::DestroyService.new(link, current_user)
end
def issues
EpicIssues::ListService.new(epic, current_user).execute
end
def authorize_admin_epic!
render_403 unless can?(current_user, :admin_epic, epic)
end
def authorize_issue_link_association!
render_404 if link.epic != epic
end
def link
@link ||= EpicIssue.find(params[:id])
end
end
......@@ -25,7 +25,7 @@ class Groups::EpicsController < Groups::ApplicationController
private
def epic
@issuable = @epic ||= @group.epics.find_by(iid: params[:id])
@issuable = @epic ||= @group.epics.find_by(iid: params[:epic_id] || params[:id])
return render_404 unless can?(current_user, :read_epic, @epic)
......
module Projects
class IssueLinksController < Projects::ApplicationController
before_action :authorize_admin_issue_link!, only: [:create, :destroy]
def index
render json: issues
end
include IssuableLinks
def create
create_params = params.slice(:issue_references)
result = IssueLinks::CreateService.new(issue, current_user, create_params).execute
render json: { message: result[:message], issues: issues }, status: result[:http_status]
end
def destroy
issue_link = IssueLink.find(params[:id])
result = IssueLinks::DestroyService.new(issue_link, current_user).execute
render json: { issues: issues }, status: result[:http_status]
end
before_action :authorize_admin_issue_link!, only: [:create, :destroy]
before_action :authorize_issue_link_association!, only: :destroy
private
......@@ -30,11 +15,27 @@ module Projects
render_403 unless can?(current_user, :admin_issue_link, @project)
end
def authorize_issue_link_association!
render_404 if link.target != issue && link.source != issue
end
def issue
@issue ||=
IssuesFinder.new(current_user, project_id: @project.id)
.execute
.find_by!(iid: params[:issue_id])
end
def create_service
IssueLinks::CreateService.new(issue, current_user, create_params)
end
def destroy_service
IssueLinks::DestroyService.new(link, current_user)
end
def link
@link ||= IssueLink.find(params[:id])
end
end
end
......@@ -10,6 +10,8 @@ module EE
belongs_to :assignee, class_name: "User"
belongs_to :group
has_many :epic_issues
validates :group, presence: true
end
......@@ -24,5 +26,13 @@ module EE
def supports_weight?
false
end
def issues(current_user)
related_issues = ::Issue.select('issues.*, epic_issues.id as epic_issue_id')
.joins(:epic_issue)
.where("epic_issues.epic_id = #{id}")
Ability.issues_readable_by_user(related_issues, current_user)
end
end
end
class EpicIssue < ActiveRecord::Base
validates :epic, :issue, presence: true
validates :issue, uniqueness: true
belongs_to :epic
belongs_to :issue
end
......@@ -55,6 +55,7 @@ module EE
enable :admin_board
enable :read_deploy_board
enable :admin_issue_link
enable :admin_epic_issue
end
rule { can?(:developer_access) }.enable :admin_board
......
module EpicIssues
class CreateService < IssuableLinks::CreateService
private
def relate_issues(referenced_issue)
link = EpicIssue.find_or_initialize_by(issue: referenced_issue)
link.epic = issuable
link.save!
end
def create_notes?
false
end
def extractor_context
{ group: issuable.group }
end
def linkable_issues(issues)
return [] unless can?(current_user, :admin_epic, issuable.group)
issues.select { |issue| issue.project.group == issuable.group }
end
end
end
module EpicIssues
class DestroyService < IssuableLinks::DestroyService
private
def create_notes?
false
end
def source
@source ||= link.epic
end
def target
@target ||= link.issue
end
def permission_to_remove_relation?
can?(current_user, :admin_epic_issue, target) && can?(current_user, :admin_epic, source)
end
end
end
module EpicIssues
class ListService < IssuableLinks::ListService
private
def issues
issuable.issues(current_user)
end
def destroy_relation_path(issue)
if can_destroy_issue_link?(issue)
group_epic_issue_path(issuable.group, issuable.iid, issue.epic_issue_id)
end
end
def can_destroy_issue_link?(issue)
Ability.allowed?(current_user, :admin_issue_link, issue) && Ability.allowed?(current_user, :admin_epic, issuable)
end
def reference(issue)
issue.to_reference(full: true)
end
end
end
module IssueLinks
module IssuableLinks
class CreateService < BaseService
def initialize(issue, user, params)
@issue, @current_user, @params = issue, user, params.dup
attr_reader :issuable, :current_user, :params
def initialize(issuable, user, params)
@issuable, @current_user, @params = issuable, user, params.dup
end
def execute
......@@ -17,20 +19,10 @@ module IssueLinks
def create_issue_links
referenced_issues.each do |referenced_issue|
create_notes(referenced_issue) if relate_issues(referenced_issue)
create_notes(referenced_issue) if relate_issues(referenced_issue) && create_notes?
end
end
# Returns a Boolean indicating if the Issue was related.
def relate_issues(referenced_issue)
IssueLink.new(source: @issue, target: referenced_issue).save
end
def create_notes(referenced_issue)
SystemNoteService.relate_issue(@issue, referenced_issue, current_user)
SystemNoteService.relate_issue(referenced_issue, @issue, current_user)
end
def referenced_issues
@referenced_issues ||= begin
target_issue = params[:target_issue]
......@@ -43,7 +35,7 @@ module IssueLinks
[]
end
issues.select { |issue| can?(current_user, :admin_issue_link, issue) }
linkable_issues(issues)
end
end
......@@ -51,10 +43,31 @@ module IssueLinks
issue_references = params[:issue_references]
text = issue_references.join(' ')
extractor = Gitlab::ReferenceExtractor.new(@issue.project, @current_user)
extractor.analyze(text)
extractor = Gitlab::ReferenceExtractor.new(issuable.project, @current_user)
extractor.analyze(text, extractor_context)
extractor.issues
end
def create_notes(referenced_issue)
SystemNoteService.relate_issue(issuable, referenced_issue, current_user)
SystemNoteService.relate_issue(referenced_issue, issuable, current_user)
end
def extractor_context
{}
end
def create_notes?
true
end
def linkable_issues(issues)
raise NotImplementedError
end
def relate_issues(referenced_issue)
raise NotImplementedError
end
end
end
module IssueLinks
module IssuableLinks
class DestroyService < BaseService
def initialize(issue_link, user)
@issue_link = issue_link
attr_reader :link, :current_user
def initialize(link, user)
@link = link
@current_user = user
@issue = issue_link.source
@referenced_issue = issue_link.target
end
def execute
return error('No Issue Link found', 404) unless permission_to_remove_relation?
remove_relation
create_notes
create_notes if create_notes?
success(message: 'Relation was removed')
end
private
def remove_relation
@issue_link.destroy!
def create_notes
SystemNoteService.unrelate_issue(source, target, current_user)
SystemNoteService.unrelate_issue(target, source, current_user)
end
def create_notes
SystemNoteService.unrelate_issue(@issue, @referenced_issue, current_user)
SystemNoteService.unrelate_issue(@referenced_issue, @issue, current_user)
def remove_relation
link.destroy!
end
def permission_to_remove_relation?
can?(current_user, :admin_issue_link, @issue) &&
can?(current_user, :admin_issue_link, @referenced_issue)
def create_notes?
true
end
end
end
module IssueLinks
module IssuableLinks
class ListService
include Gitlab::Routing
def initialize(issue, user)
@issue, @current_user, @project = issue, user, issue.project
attr_reader :issuable, :current_user
def initialize(issuable, user)
@issuable, @current_user = issuable, user
end
def execute
......@@ -12,7 +14,7 @@ module IssueLinks
id: referenced_issue.id,
title: referenced_issue.title,
state: referenced_issue.state,
reference: referenced_issue.to_reference(@project),
reference: reference(referenced_issue),
path: project_issue_path(referenced_issue.project, referenced_issue.iid),
destroy_relation_path: destroy_relation_path(referenced_issue)
}
......@@ -21,26 +23,12 @@ module IssueLinks
private
def issues
@issue.related_issues(@current_user, preload: { project: :namespace })
end
def destroy_relation_path(issue)
# Make sure the user can admin both the current issue AND the
# referenced issue projects in order to return the removal link.
if can_destroy_issue_link_on_current_project? && can_destroy_issue_link?(issue.project)
project_issue_link_path(@project, @issue.iid, issue.issue_link_id)
end
end
def can_destroy_issue_link_on_current_project?
return @can_destroy_on_current_project if defined?(@can_destroy_on_current_project)
@can_destroy_on_current_project = can_destroy_issue_link?(@project)
raise NotImplementedError
end
def can_destroy_issue_link?(project)
Ability.allowed?(@current_user, :admin_issue_link, project)
def reference(issue)
issue.to_reference(issuable.project)
end
end
end
module IssueLinks
class CreateService < IssuableLinks::CreateService
def relate_issues(referenced_issue)
IssueLink.new(source: issuable, target: referenced_issue).save
end
def linkable_issues(issues)
issues.select { |issue| can?(current_user, :admin_issue_link, issue) }
end
end
end
module IssueLinks
class DestroyService < IssuableLinks::DestroyService
private
def source
@source ||= link.source
end
def target
@target ||= link.target
end
def permission_to_remove_relation?
can?(current_user, :admin_issue_link, source) && can?(current_user, :admin_issue_link, target)
end
end
end
module IssueLinks
class ListService < IssuableLinks::ListService
private
def issues
issuable.related_issues(current_user, preload: { project: :namespace })
end
def destroy_relation_path(issue)
current_project = issuable.project
# Make sure the user can admin both the current issue AND the
# referenced issue projects in order to return the removal link.
if can_destroy_issue_link_on_current_project?(current_project) && can_destroy_issue_link?(issue.project)
project_issue_link_path(current_project, issuable.iid, issue.issue_link_id)
end
end
def can_destroy_issue_link_on_current_project?(current_project)
return @can_destroy_on_current_project if defined?(@can_destroy_on_current_project)
@can_destroy_on_current_project = can_destroy_issue_link?(current_project)
end
def can_destroy_issue_link?(project)
Ability.allowed?(current_user, :admin_issue_link, project)
end
end
end
......@@ -6,8 +6,7 @@ module Banzai
def nodes_visible_to_user(user, nodes)
issues = issues_for_nodes(nodes)
readable_issues = Ability
.issues_readable_by_user(issues.values, user).to_set
readable_issues = Ability.issues_readable_by_user(issues.values, user).to_set
nodes.select do |node|
readable_issues.include?(issues[node])
......
require 'spec_helper'
describe Groups::EpicIssuesController do
let(:group) { create(:group, :public) }
let(:project) { create(:project, :public, group: group) }
let(:epic) { create(:epic, group: group) }
let(:issue) { create(:issue, project: project) }
let(:user) { create(:user) }
before do
sign_in(user)
end
describe 'GET #index' do
let!(:epic_issues) { create(:epic_issue, epic: epic, issue: issue) }
before do
group.add_developer(user)
get :index, group_id: group, epic_id: epic.to_param
end
it 'returns status 200' do
expect(response.status).to eq(200)
end
it 'returns the correct json' do
expected_result = [
{
'id' => issue.id,
'title' => issue.title,
'state' => issue.state,
'reference' => "#{project.full_path}##{issue.iid}",
'path' => "/#{project.full_path}/issues/#{issue.iid}",
'destroy_relation_path' => "/groups/#{group.full_path}/-/epics/#{epic.iid}/issues/#{epic_issues.id}"
}
]
expect(JSON.parse(response.body)).to eq(expected_result)
end
end
describe 'POST #create' do
subject do
reference = [issue.to_reference(full: true)]
post :create, group_id: group, epic_id: epic.to_param, issue_references: reference
end
context 'when user has permissions to create requested association' do
before do
group.add_developer(user)
end
it 'returns correct response for the correct issue reference' do
subject
list_service_response = EpicIssues::ListService.new(epic, user).execute
expect(response).to have_gitlab_http_status(200)
expect(json_response).to eq('message' => nil, 'issues' => list_service_response.as_json)
end
it 'creates a new EpicIssue record' do
expect { subject }.to change { EpicIssue.count }.from(0).to(1)
end
end
context 'when user does not have permissions to create requested association' do
it 'returns correct response for the correct issue reference' do
subject
expect(response).to have_gitlab_http_status(403)
end
it 'does not create a new EpicIssue record' do
expect { subject }.not_to change { EpicIssue.count }.from(0)
end
end
end
describe 'DELETE #destroy' do
let!(:epic_issue) { create(:epic_issue, epic: epic, issue: issue) }
subject do
delete :destroy, group_id: group, epic_id: epic.to_param, id: epic_issue.id
end
context 'when user has permissions to detele the link' do
before do
group.add_developer(user)
end
it 'returns status 200' do
subject
expect(response.status).to eq(200)
end
it 'destroys the link' do
expect { subject }.to change { EpicIssue.count }.from(1).to(0)
end
end
context 'when user does not have permissions to delete the link' do
it 'returns status 404' do
subject
expect(response.status).to eq(403)
end
it 'does not destroy the link' do
expect { subject }.not_to change { EpicIssue.count }.from(1)
end
end
context 'when the epic from the association does not equal epic from the path' do
subject do
delete :destroy, group_id: group, epic_id: another_epic.to_param, id: epic_issue.id
end
let(:another_epic) { create(:epic, group: group) }
before do
group.add_developer(user)
end
it 'returns status 404' do
subject
expect(response.status).to eq(404)
end
it 'does not destroy the link' do
expect { subject }.not_to change { EpicIssue.count }.from(1)
end
end
context 'when the epic_issue record does not exists' do
it 'returns status 404' do
delete :destroy, group_id: group, epic_id: epic.to_param, id: 9999
expect(response.status).to eq(403)
end
end
end
end
require 'spec_helper'
describe 'Epic Issues', :js do
let(:user) { create(:user) }
let(:group) { create(:group, :public) }
let(:epic) { create(:epic, group: group) }
let(:public_project) { create(:project, :public, group: group) }
let(:private_project) { create(:project, :private, group: group) }
let(:public_issue) { create(:issue, project: public_project) }
let(:private_issue) { create(:issue, project: private_project) }
let!(:epic_issues) do
[
create(:epic_issue, epic: epic, issue: public_issue),
create(:epic_issue, epic: epic, issue: private_issue)
]
end
def visit_epic
sign_in(user)
visit group_epic_path(group, epic)
wait_for_requests
end
context 'when user is not a group member of a public group' do
before do
visit_epic
end
it 'user can see issues from public project but cannot delete the associations' do
within('.related-issues-block ul.issuable-list') do
expect(page).to have_selector('li', count: 1)
expect(page).to have_content(public_issue.title)
expect(page).not_to have_selector('button.js-issue-token-remove-button')
end
end
it 'user cannot add new issues to the epic' do
expect(page).not_to have_selector('.related-issues-block h3.panel-title button')
end
end
context 'when user is a group member' do
before do
group.add_developer(user)
visit_epic
end
it 'user can see all issues of the group and delete the associations' do
within('.related-issues-block ul.issuable-list') do
expect(page).to have_selector('li', count: 2)
expect(page).to have_content(public_issue.title)
expect(page).to have_content(private_issue.title)
first('li button.js-issue-token-remove-button').click
end
wait_for_requests
within('.related-issues-block ul.issuable-list') do
expect(page).to have_selector('li', count: 1)
end
end
it 'user can add new issues to the epic' do
issue_to_add = create(:issue, project: private_project)
issue_invalid = create(:issue)
references = "#{issue_to_add.to_reference(full: true)} #{issue_invalid.to_reference(full: true)}"
find('.related-issues-block h3.panel-title button').click
find('.js-add-issuable-form-input').set references
find('.js-add-issuable-form-add-button').click
wait_for_requests
within('.related-issues-block ul.issuable-list') do
expect(page).to have_selector('li', count: 3)
expect(page).to have_content(issue_to_add.title)
end
end
end
end
......@@ -5,6 +5,7 @@ describe Epic do
subject { build(:epic) }
it { is_expected.to belong_to(:author).class_name('User') }
it { is_expected.to belong_to(:assignee).class_name('User') }
it { is_expected.to belong_to(:group) }
end
......@@ -21,4 +22,40 @@ describe Epic do
it { is_expected.to include_module(InternalId) }
end
describe '#issues' do
let(:user) { create(:user) }
let(:group) { create(:group, :private) }
let(:project) { create(:project, group: group) }
let(:project2) { create(:project, group: group) }
let!(:epic) { create(:epic, group: group) }
let!(:issue) { create(:issue, project: project)}
let!(:lone_issue) { create(:issue, project: project)}
let!(:other_issue) { create(:issue, project: project2)}
let!(:epic_issues) do
[
create(:epic_issue, epic: epic, issue: issue),
create(:epic_issue, epic: epic, issue: other_issue)
]
end
let(:result) { epic.issues(user) }
it 'returns all issues if a user has access to them' do
group.add_developer(user)
expect(result.count).to eq(2)
expect(result.map(&:id)).to match_array([issue.id, other_issue.id])
expect(result.map(&:epic_issue_id)).to match_array(epic_issues.map(&:id))
end
it 'does not return issues user can not see' do
project.add_developer(user)
expect(result.count).to eq(1)
expect(result.map(&:id)).to match_array([issue.id])
expect(result.map(&:epic_issue_id)).to match_array([epic_issues.first.id])
end
end
end
require 'spec_helper'
describe EpicIssues::CreateService do
describe '#execute' do
let(:group) { create :group }
let(:epic) { create :epic, group: group }
let(:project) { create(:project, group: group) }
let(:issue) { create :issue, project: project }
let(:user) { create :user }
let(:valid_reference) { issue.to_reference(full: true) }
def assign_issue(references)
params = { issue_references: references }
described_class.new(epic, user, params).execute
end
shared_examples 'returns success' do
it 'creates relationships' do
expect { subject }.to change(EpicIssue, :count).from(0).to(1)
expect(EpicIssue.find_by!(issue_id: issue.id)).to have_attributes(epic: epic)
end
it 'returns success status' do
expect(subject).to eq(status: :success)
end
end
shared_examples 'returns an error' do
it 'returns an error' do
expect(subject).to eq(message: 'No Issue found for given params', status: :error, http_status: 404)
end
it 'no relationship is created' do
expect { subject }.not_to change { EpicIssue.count }
end
end
context 'when user has permissions to link the issue' do
before do
group.add_developer(user)
end
context 'when the reference list is empty' do
it 'returns an error' do
expect(assign_issue([])).to eq(message: 'No Issue found for given params', status: :error, http_status: 404)
end
end
context 'when there is an issue to relate' do
context 'when shortcut for Issue is given' do
subject { assign_issue([issue.to_reference]) }
include_examples 'returns an error'
end
context 'when a full reference is given' do
subject { assign_issue([valid_reference]) }
include_examples 'returns success'
it 'does not perofrm N + 1 queries' do
params = { issue_references: [valid_reference] }
control_count = ActiveRecord::QueryRecorder.new { described_class.new(epic, user, params).execute }.count
user = create(:user)
group = create(:group)
project = create(:project, group: group)
issues = create_list(:issue, 5, project: project)
epic = create(:epic, group: group)
group.add_developer(user)
params = { issue_references: issues.map { |i| i.to_reference(full: true) } }
expect { described_class.new(epic, user, params).execute }.not_to exceed_query_limit(control_count)
end
end
context 'when an issue links is given' do
subject { assign_issue([IssuesHelper.url_for_issue(issue.iid, issue.project)]) }
include_examples 'returns success'
end
end
end
context 'when user does not have permissions to link the issue' do
subject { assign_issue([valid_reference]) }
include_examples 'returns an error'
end
context 'when an issue is already assigned to another epic' do
before do
group.add_developer(user)
create(:epic_issue, epic: epic, issue: issue)
end
let(:another_epic) { create(:epic, group: group) }
subject do
params = { issue_references: [valid_reference] }
described_class.new(another_epic, user, params).execute
end
it 'does not create a new association' do
expect { subject }.not_to change(EpicIssue, :count).from(1)
end
it 'updates the existing association' do
expect { subject }.to change { EpicIssue.last.epic }.from(epic).to(another_epic)
end
it 'returns success status' do
is_expected.to eq(status: :success)
end
end
context 'when issue from non group project is given' do
subject { assign_issue([another_issue.to_reference(full: true)]) }
let(:another_issue) { create :issue }
before do
group.add_developer(user)
another_issue.project.add_developer(user)
end
include_examples 'returns an error'
end
end
end
require 'spec_helper'
describe EpicIssues::DestroyService do
describe '#execute' do
let(:user) { create(:user) }
let(:group) { create(:group, :public) }
let(:project) { create(:project, group: group) }
let(:epic) { create(:epic, group: group) }
let(:issue) { create(:issue, project: project) }
let!(:epic_issue) { create(:epic_issue, epic: epic, issue: issue) }
subject { described_class.new(epic_issue, user).execute }
context 'when user has permissions to remove associations' do
before do
group.add_reporter(user)
end
it 'removes related issue' do
expect { subject }.to change { EpicIssue.count }.from(1).to(0)
end
it 'returns success message' do
is_expected.to eq(message: 'Relation was removed', status: :success)
end
end
context 'user does not have permissions to remove associations' do
it 'does not remove relation' do
expect { subject }.not_to change { EpicIssue.count }.from(1)
end
it 'returns error message' do
is_expected.to eq(message: 'No Issue Link found', status: :error, http_status: 404)
end
end
end
end
require 'spec_helper'
describe EpicIssues::ListService do
let(:user) { create :user }
let(:group) { create(:group, :private) }
let(:project) { create(:project_empty_repo, group: group) }
let(:other_project) { create(:project_empty_repo, group: group) }
let(:epic) { create(:epic, group: group) }
let(:issue1) { create :issue, project: project }
let(:issue2) { create :issue, project: project }
let(:issue3) { create :issue, project: other_project }
let!(:epic_issue1) { create(:epic_issue, issue: issue1, epic: epic) }
let!(:epic_issue2) { create(:epic_issue, issue: issue2, epic: epic) }
let!(:epic_issue3) { create(:epic_issue, issue: issue3, epic: epic) }
describe '#execute' do
subject { described_class.new(epic, user).execute }
context 'user can see all issues and destroy their associations' do
before do
group.add_developer(user)
end
it 'returns related issues JSON' do
expected_result = [
{
id: issue1.id,
title: issue1.title,
state: issue1.state,
reference: issue1.to_reference(full: true),
path: "/#{project.full_path}/issues/#{issue1.iid}",
destroy_relation_path: "/groups/#{group.full_path}/-/epics/#{epic.iid}/issues/#{epic_issue1.id}"
},
{
id: issue2.id,
title: issue2.title,
state: issue2.state,
reference: issue2.to_reference(full: true),
path: "/#{project.full_path}/issues/#{issue2.iid}",
destroy_relation_path: "/groups/#{group.full_path}/-/epics/#{epic.iid}/issues/#{epic_issue2.id}"
},
{
id: issue3.id,
title: issue3.title,
state: issue3.state,
reference: issue3.to_reference(full: true),
path: "/#{other_project.full_path}/issues/#{issue3.iid}",
destroy_relation_path: "/groups/#{group.full_path}/-/epics/#{epic.iid}/issues/#{epic_issue3.id}"
}
]
expect(subject).to match_array(expected_result)
end
end
context 'user can see only some issues' do
before do
project.add_developer(user)
end
it 'returns related issues JSON' do
expected_result = [
{
id: issue1.id,
title: issue1.title,
state: issue1.state,
reference: issue1.to_reference(full: true),
path: "/#{project.full_path}/issues/#{issue1.iid}",
destroy_relation_path: nil
},
{
id: issue2.id,
title: issue2.title,
state: issue2.state,
reference: issue2.to_reference(full: true),
path: "/#{project.full_path}/issues/#{issue2.iid}",
destroy_relation_path: nil
}
]
expect(subject).to match_array(expected_result)
end
end
end
end
FactoryGirl.define do
factory :epic_issue do
epic
issue
end
end
......@@ -176,6 +176,7 @@ describe IssuablesHelper do
'endpoint' => "/#{@project.full_path}/issues/#{issue.iid}",
'canUpdate' => true,
'canDestroy' => true,
'canAdmin' => true,
'issuableRef' => "##{issue.iid}",
'markdownPreviewPath' => "/#{@project.full_path}/preview_markdown",
'markdownDocsPath' => '/help/user/markdown',
......@@ -197,8 +198,10 @@ describe IssuablesHelper do
expected_data = {
'endpoint' => "/groups/#{@group.full_path}/-/epics/#{epic.iid}",
'issueLinksEndpoint' => "/groups/#{@group.full_path}/-/epics/#{epic.iid}/issues",
'canUpdate' => true,
'canDestroy' => true,
'canAdmin' => true,
'issuableRef' => nil,
'markdownPreviewPath' => "/groups/#{@group.full_path}/preview_markdown",
'markdownDocsPath' => '/help/user/markdown',
......
......@@ -16,9 +16,15 @@ describe('EpicShowApp', () => {
let sidebarVm;
const interceptor = (request, next) => {
next(request.respondWith(JSON.stringify(issueShowData.initialRequest), {
status: 200,
}));
if (request.url === '/realtime_changes') {
next(request.respondWith(JSON.stringify(issueShowData.initialRequest), {
status: 200,
}));
} else {
next(request.respondWith(null, {
status: 404,
}));
}
};
beforeEach(() => {
......
export const contentProps = {
endpoint: '',
canAdmin: true,
canUpdate: true,
canDestroy: true,
markdownPreviewPath: '',
markdownDocsPath: '',
issueLinksEndpoint: '/',
groupPath: '',
initialTitleHtml: '',
initialTitleText: '',
......
......@@ -121,6 +121,48 @@ describe('AddIssuableForm', () => {
});
});
describe('autocomplete', () => {
describe('with autoCompleteSources', () => {
beforeEach(() => {
vm = new AddIssuableForm({
propsData: {
inputValue: '',
autoCompleteSources: {
issues: '/fake/issues/path',
},
},
}).$mount();
});
it('shows placeholder text', () => {
expect(vm.$refs.input.placeholder).toEqual('Paste issue link or <#issue id>');
});
it('has GfmAutoComplete', () => {
expect(vm.gfmAutoComplete).toBeDefined();
});
});
describe('with no autoCompleteSources', () => {
beforeEach(() => {
vm = new AddIssuableForm({
propsData: {
inputValue: '',
autoCompleteSources: {},
},
}).$mount();
});
it('shows placeholder text', () => {
expect(vm.$refs.input.placeholder).toEqual('Paste issue link');
});
it('does not have GfmAutoComplete', () => {
expect(vm.gfmAutoComplete).not.toBeDefined();
});
});
});
describe('methods', () => {
let addIssuableFormInputSpy;
let addIssuableFormBlurSpy;
......@@ -147,6 +189,9 @@ describe('AddIssuableForm', () => {
pendingIssuables: [
issuable1,
],
autoCompleteSources: {
issues: '/fake/issues/path',
},
},
}).$mount(el);
});
......
......@@ -74,7 +74,7 @@ describe('RelatedIssuesBlock', () => {
beforeEach(() => {
vm = new RelatedIssuesBlock({
propsData: {
canAddRelatedIssues: true,
canAdmin: true,
},
}).$mount();
});
......
import Vue from 'vue';
import relatedIssuesRoot from '~/issuable/related_issues/components/related_issues_root.vue';
import relatedIssuesService from '~/issuable/related_issues/services/related_issues_service';
const defaultProps = {
endpoint: '/foo/bar/issues/1/related_issues',
......@@ -41,11 +42,17 @@ describe('RelatedIssuesRoot', () => {
describe('methods', () => {
describe('onRelatedIssueRemoveRequest', () => {
beforeEach(() => {
beforeEach((done) => {
spyOn(relatedIssuesService.prototype, 'fetchRelatedIssues').and.returnValue(Promise.reject());
vm = new RelatedIssuesRoot({
propsData: defaultProps,
}).$mount();
vm.store.setRelatedIssues([issuable1]);
setTimeout(() => {
vm.store.setRelatedIssues([issuable1]);
done();
});
});
it('remove related issue and succeeds', (done) => {
......@@ -131,6 +138,7 @@ describe('RelatedIssuesRoot', () => {
describe('onPendingFormSubmit', () => {
beforeEach(() => {
spyOn(relatedIssuesService.prototype, 'fetchRelatedIssues').and.returnValue(Promise.reject());
vm = new RelatedIssuesRoot({
propsData: defaultProps,
}).$mount();
......@@ -243,23 +251,21 @@ describe('RelatedIssuesRoot', () => {
});
describe('fetchRelatedIssues', () => {
beforeEach(() => {
beforeEach((done) => {
vm = new RelatedIssuesRoot({
propsData: defaultProps,
}).$mount();
// wait for internal call to fetchRelatedIssues to resolve
setTimeout(() => Vue.nextTick(done));
});
describe('when the network has not responded yet', () => {
it('should be fetching', (done) => {
vm.fetchRelatedIssues();
expect(vm.isFetching).toEqual(true);
setTimeout(() => {
Vue.nextTick(() => {
expect(vm.isFetching).toEqual(true);
done();
});
});
setTimeout(() => Vue.nextTick(done));
});
});
......
......@@ -81,10 +81,6 @@ describe Gitlab::BackgroundMigration::PopulateForkNetworksRange, :migration, sch
it 'only processes a single batch of links at a time' do
expect(fork_network_members.count).to eq(5)
migration.perform(3, 5)
expect(fork_network_members.count).to eq(7)
end
it 'can be repeated without effect' do
......
......@@ -18,6 +18,8 @@ issues:
- metrics
- timelogs
- issue_assignees
- epic_issue
- epic
events:
- author
- project
......@@ -316,3 +318,6 @@ push_event_payload:
issue_assignees:
- issue
- assignee
epic_issues:
- issue
- epic
......@@ -124,6 +124,29 @@ describe Projects::IssueLinksController do
expect(json_response).to eq('issues' => list_service_response.as_json)
end
end
context 'when non of issues of the link is not the issue requested in the path' do
let(:referenced_issue) { create(:issue, project: project) }
let(:another_issue) { create(:issue, project: project) }
let(:issue) { create(:issue, project: project) }
let(:user_role) { :developer }
let!(:issue_link) { create :issue_link, source: another_issue, target: referenced_issue }
subject do
delete namespace_project_issue_link_path(issue_links_params(id: issue_link.id))
end
it 'returns 404' do
subject
expect(response).to have_gitlab_http_status(404)
end
it 'does not delete the link' do
expect { subject }.not_to change { IssueLink.count }.from(1)
end
end
end
def issue_links_params(opts = {})
......
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