Commit 1da14e0d authored by Jacob Schatz's avatar Jacob Schatz

Merge branch '3550-show-epic' into 'master'

Show epic view

Closes #3550 and #3693

See merge request gitlab-org/gitlab-ee!3126
parents 34528932 004e9e0e
......@@ -7,6 +7,54 @@ module IssuableActions
before_action :authorize_admin_issuable!, only: :bulk_update
end
def show
respond_to do |format|
format.html do
render show_view
end
format.json do
render json: serializer.represent(issuable, serializer: params[:serializer])
end
end
end
def update
@issuable = update_service.execute(issuable)
respond_to do |format|
format.html do
recaptcha_check_with_fallback { render :edit }
end
format.json do
render_entity_json
end
end
rescue ActiveRecord::StaleObjectError
render_conflict_response
end
def realtime_changes
Gitlab::PollingInterval.set_header(response, interval: 3_000)
response = {
title: view_context.markdown_field(issuable, :title),
title_text: issuable.title,
description: view_context.markdown_field(issuable, :description),
description_text: issuable.description,
task_status: issuable.task_status
}
if issuable.edited?
response[:updated_at] = issuable.updated_at
response[:updated_by_name] = issuable.last_edited_by.name
response[:updated_by_path] = user_path(issuable.last_edited_by)
end
render json: response
end
def destroy
issuable.destroy
destroy_method = "destroy_#{issuable.class.name.underscore}".to_sym
......@@ -68,6 +116,10 @@ module IssuableActions
end
end
def authorize_update_issuable!
render_404 unless can?(current_user, :"update_#{resource_name}", issuable)
end
def bulk_update_params
permitted_keys = [
:issuable_ids,
......@@ -92,4 +144,24 @@ module IssuableActions
def resource_name
@resource_name ||= controller_name.singularize
end
def render_entity_json
if @issuable.valid?
render json: serializer.represent(@issuable)
else
render json: { errors: @issuable.errors.full_messages }, status: :unprocessable_entity
end
end
def show_view
'show'
end
def serializer
raise NotImplementedError
end
def update_service
raise NotImplementedError
end
end
......@@ -16,7 +16,7 @@ class Projects::IssuesController < Projects::ApplicationController
before_action :authorize_create_issue!, only: [:new, :create]
# Allow modify issue
before_action :authorize_update_issue!, only: [:edit, :update, :move]
before_action :authorize_update_issuable!, only: [:edit, :update, :move]
# Allow create a new branch and empty WIP merge request from current issue
before_action :authorize_create_merge_request!, only: [:create_merge_request]
......@@ -69,18 +69,6 @@ class Projects::IssuesController < Projects::ApplicationController
respond_with(@issue)
end
def show
@noteable = @issue
@note = @project.notes.new(noteable: @issue)
respond_to do |format|
format.html
format.json do
render json: serializer.represent(@issue, serializer: params[:serializer])
end
end
end
def discussions
notes = @issue.notes
.inc_relations_for_view
......@@ -122,25 +110,6 @@ class Projects::IssuesController < Projects::ApplicationController
end
end
def update
update_params = issue_params.merge(spammable_params)
@issue = Issues::UpdateService.new(project, current_user, update_params).execute(issue)
respond_to do |format|
format.html do
recaptcha_check_with_fallback { render :edit }
end
format.json do
render_issue_json
end
end
rescue ActiveRecord::StaleObjectError
render_conflict_response
end
def move
params.require(:move_to_project_id)
......@@ -198,26 +167,6 @@ class Projects::IssuesController < Projects::ApplicationController
end
end
def realtime_changes
Gitlab::PollingInterval.set_header(response, interval: 3_000)
response = {
title: view_context.markdown_field(@issue, :title),
title_text: @issue.title,
description: view_context.markdown_field(@issue, :description),
description_text: @issue.description,
task_status: @issue.task_status
}
if @issue.edited?
response[:updated_at] = @issue.updated_at
response[:updated_by_name] = @issue.last_edited_by.name
response[:updated_by_path] = user_path(@issue.last_edited_by)
end
render json: response
end
def create_merge_request
result = ::MergeRequests::CreateFromIssueService.new(project, current_user, issue_iid: issue.iid).execute
......@@ -233,7 +182,8 @@ class Projects::IssuesController < Projects::ApplicationController
def issue
return @issue if defined?(@issue)
# The Sortable default scope causes performance issues when used with find_by
@noteable = @issue ||= @project.issues.where(iid: params[:id]).reorder(nil).take!
@issuable = @noteable = @issue ||= @project.issues.where(iid: params[:id]).reorder(nil).take!
@note = @project.notes.new(noteable: @issuable)
return render_404 unless can?(current_user, :read_issue, @issue)
......@@ -248,14 +198,6 @@ class Projects::IssuesController < Projects::ApplicationController
project_issue_path(@project, @issue)
end
def authorize_update_issue!
render_404 unless can?(current_user, :update_issue, @issue)
end
def authorize_admin_issues!
render_404 unless can?(current_user, :admin_issue, @project)
end
def authorize_create_merge_request!
render_404 unless can?(current_user, :push_code, @project) && @issue.can_be_worked_on?(current_user)
end
......@@ -307,4 +249,9 @@ class Projects::IssuesController < Projects::ApplicationController
def serializer
IssueSerializer.new(current_user: current_user, project: issue.project)
end
def update_service
update_params = issue_params.merge(spammable_params)
Issues::UpdateService.new(project, current_user, update_params)
end
end
......@@ -11,7 +11,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
skip_before_action :merge_request, only: [:index, :bulk_update]
skip_before_action :ensure_ref_fetched, only: [:index, :bulk_update]
before_action :authorize_update_merge_request!, only: [:close, :edit, :update, :remove_wip, :sort]
before_action :authorize_update_issuable!, only: [:close, :edit, :update, :remove_wip, :sort]
before_action :authenticate_user!, only: [:assign_related_issues]
......@@ -259,14 +259,6 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
alias_method :issuable, :merge_request
alias_method :awardable, :merge_request
def authorize_update_merge_request!
return render_404 unless can?(current_user, :update_merge_request, @merge_request)
end
def authorize_admin_merge_request!
return render_404 unless can?(current_user, :admin_merge_request, @merge_request)
end
def validates_merge_request
# Show git not found page
# if there is no saved commits between source & target branch
......
......@@ -73,11 +73,13 @@ module GitlabRoutingHelper
project_commit_url(entity.project, entity.sha, *args)
end
def preview_markdown_path(project, *args)
def preview_markdown_path(parent, *args)
return group_preview_markdown_path(parent) if parent.is_a?(Group)
if @snippet.is_a?(PersonalSnippet)
preview_markdown_snippets_path
else
preview_markdown_project_path(project, *args)
preview_markdown_project_path(parent, *args)
end
end
......
......@@ -212,15 +212,13 @@ module IssuablesHelper
def issuable_initial_data(issuable)
data = {
endpoint: project_issue_path(@project, issuable),
canUpdate: can?(current_user, :update_issue, issuable),
canDestroy: can?(current_user, :destroy_issue, issuable),
endpoint: issuable_path(issuable),
canUpdate: can?(current_user, :"update_#{issuable.to_ability_name}", issuable),
canDestroy: can?(current_user, :"destroy_#{issuable.to_ability_name}", issuable),
issuableRef: issuable.to_reference,
markdownPreviewPath: preview_markdown_path(@project),
markdownPreviewPath: preview_markdown_path(parent),
markdownDocsPath: help_page_path('user/markdown'),
issuableTemplates: issuable_templates(issuable),
projectPath: ref_project.path,
projectNamespace: ref_project.namespace.full_path,
initialTitleHtml: markdown_field(issuable, :title),
initialTitleText: issuable.title,
initialDescriptionHtml: markdown_field(issuable, :description),
......@@ -228,6 +226,12 @@ module IssuablesHelper
initialTaskStatus: issuable.task_status
}
if parent.is_a?(Group)
data[:groupPath] = parent.path
else
data.merge!(projectPath: ref_project.path, projectNamespace: ref_project.namespace.full_path)
end
data.merge!(updated_at_by(issuable))
data.to_json
......@@ -264,12 +268,7 @@ module IssuablesHelper
end
def issuable_path(issuable, *options)
case issuable
when Issue
issue_path(issuable, *options)
when MergeRequest
merge_request_path(issuable, *options)
end
polymorphic_path(issuable, *options)
end
def issuable_url(issuable, *options)
......@@ -370,4 +369,8 @@ module IssuablesHelper
fullPath: @project.full_path
}
end
def parent
@project || @group
end
end
......@@ -49,7 +49,8 @@ module CacheMarkdownField
# Always include a project key, or Banzai complains
project = self.project if self.respond_to?(:project)
context = cached_markdown_fields[field].merge(project: project)
group = self.group if self.respond_to?(:group)
context = cached_markdown_fields[field].merge(project: project, group: group)
# Banzai is less strict about authors, so don't always have an author key
context[:author] = self.author if self.respond_to?(:author)
......
......@@ -14,7 +14,6 @@ module Issuable
include StripAttribute
include Awardable
include Taskable
include TimeTrackable
include Importable
include Editable
include AfterCommitQueue
......@@ -98,8 +97,6 @@ module Issuable
strip_attributes :title
acts_as_paranoid
after_save :record_metrics, unless: :imported?
# We want to use optimistic lock for cases when only title or description are involved
......
# Placeholder class for model that is implemented in EE
# It will reserve (ee#3853) '&' as a reference prefix, but the table does not exists in CE
class Epic < ActiveRecord::Base
prepend EE::Epic
# TODO: this will be implemented as part of #3853
def to_reference
end
end
......@@ -199,6 +199,12 @@ class Group < Namespace
add_user(user, :owner, current_user: current_user)
end
def member?(user, min_access_level = Gitlab::Access::GUEST)
return false unless user
max_member_access_for_user(user) >= min_access_level
end
def has_owner?(user)
return false unless user
......
......@@ -14,6 +14,7 @@ class Issue < ActiveRecord::Base
include FasterCacheKeys
include RelativePositioning
include CreatedAtFilterable
include TimeTrackable
WEIGHT_RANGE = 1..9
WEIGHT_ALL = 'Everything'.freeze
......@@ -86,6 +87,8 @@ class Issue < ActiveRecord::Base
end
end
acts_as_paranoid
def self.reference_prefix
'#'
end
......
......@@ -7,6 +7,7 @@ class MergeRequest < ActiveRecord::Base
include Elastic::MergeRequestsSearch
include IgnorableColumn
include CreatedAtFilterable
include TimeTrackable
ignore_column :locked_at
......@@ -123,6 +124,8 @@ class MergeRequest < ActiveRecord::Base
after_save :keep_around_commit
acts_as_paranoid
def self.reference_prefix
'!'
end
......
......@@ -3,6 +3,8 @@
# A note of this type is never resolvable.
class Note < ActiveRecord::Base
extend ActiveModel::Naming
prepend EE::Note
include Gitlab::CurrentSettings
include Participable
include Mentionable
......@@ -70,7 +72,7 @@ class Note < ActiveRecord::Base
delegate :title, to: :noteable, allow_nil: true
validates :note, presence: true
validates :project, presence: true, unless: :for_personal_snippet?
validates :project, presence: true, if: :for_project_noteable?
# Attachments are deprecated and are handled by Markdown uploader
validates :attachment, file_size: { maximum: :max_attachment_size }
......@@ -116,7 +118,7 @@ class Note < ActiveRecord::Base
after_initialize :ensure_discussion_id
before_validation :nullify_blank_type, :nullify_blank_line_code
before_validation :set_discussion_id, on: :create
after_save :keep_around_commit, unless: :for_personal_snippet?
after_save :keep_around_commit, if: :for_project_noteable?
after_save :expire_etag_cache
after_destroy :expire_etag_cache
......@@ -214,6 +216,10 @@ class Note < ActiveRecord::Base
noteable.is_a?(PersonalSnippet)
end
def for_project_noteable?
!for_personal_snippet?
end
def skip_project_check?
for_personal_snippet?
end
......
class IssuableEntity < Grape::Entity
include RequestAwareEntity
expose :id
expose :iid
expose :author_id
expose :description
expose :lock_version
expose :milestone_id
expose :state
expose :title
expose :updated_by_id
expose :created_at
expose :updated_at
expose :deleted_at
expose :time_estimate
expose :total_time_spent
expose :human_time_estimate
expose :human_total_time_spent
expose :milestone, using: API::Entities::Milestone
expose :labels, using: LabelEntity
end
class IssueEntity < IssuableEntity
include RequestAwareEntity
include TimeTrackableEntity
expose :state
expose :deleted_at
expose :branch_name
expose :confidential
expose :discussion_locked
......
class MergeRequestEntity < IssuableEntity
include RequestAwareEntity
include TimeTrackableEntity
expose :state
expose :deleted_at
expose :in_progress_merge_commit_sha
expose :merge_commit_sha
expose :merge_error
......
module TimeTrackableEntity
extend ActiveSupport::Concern
extend Grape
included do
expose :time_estimate
expose :total_time_spent
expose :human_time_estimate
expose :human_total_time_spent
end
end
module Issuable
class CommonSystemNotesService < ::BaseService
attr_reader :issuable
def execute(issuable, old_labels)
@issuable = issuable
if issuable.previous_changes.include?('title')
create_title_change_note(issuable.previous_changes['title'].first)
end
handle_description_change_note
handle_time_tracking_note if issuable.is_a?(TimeTrackable)
create_labels_note(old_labels) if issuable.labels != old_labels
create_discussion_lock_note if issuable.previous_changes.include?('discussion_locked')
create_milestone_note if issuable.previous_changes.include?('milestone_id')
end
private
def handle_time_tracking_note
if issuable.previous_changes.include?('time_estimate')
create_time_estimate_note
end
if issuable.time_spent?
create_time_spent_note
end
end
def handle_description_change_note
if issuable.previous_changes.include?('description')
if issuable.tasks? && issuable.updated_tasks.any?
create_task_status_note
else
# TODO: Show this note if non-task content was modified.
# https://gitlab.com/gitlab-org/gitlab-ce/issues/33577
create_description_change_note
end
end
end
def create_labels_note(old_labels)
added_labels = issuable.labels - old_labels
removed_labels = old_labels - issuable.labels
SystemNoteService.change_label(issuable, issuable.project, current_user, added_labels, removed_labels)
end
def create_title_change_note(old_title)
SystemNoteService.change_title(issuable, issuable.project, current_user, old_title)
end
def create_description_change_note
SystemNoteService.change_description(issuable, issuable.project, current_user)
end
def create_task_status_note
issuable.updated_tasks.each do |task|
SystemNoteService.change_task_status(issuable, issuable.project, current_user, task)
end
end
def create_time_estimate_note
SystemNoteService.change_time_estimate(issuable, issuable.project, current_user)
end
def create_time_spent_note
SystemNoteService.change_time_spent(issuable, issuable.project, issuable.time_spent_user)
end
def create_milestone_note
SystemNoteService.change_milestone(issuable, issuable.project, current_user, issuable.milestone)
end
def create_discussion_lock_note
SystemNoteService.discussion_lock(issuable, current_user)
end
end
end
......@@ -3,56 +3,10 @@ class IssuableBaseService < BaseService
private
def create_milestone_note(issuable)
SystemNoteService.change_milestone(
issuable, issuable.project, current_user, issuable.milestone)
end
def create_labels_note(issuable, old_labels)
added_labels = issuable.labels - old_labels
removed_labels = old_labels - issuable.labels
SystemNoteService.change_label(
issuable, issuable.project, current_user, added_labels, removed_labels)
end
def create_title_change_note(issuable, old_title)
SystemNoteService.change_title(
issuable, issuable.project, current_user, old_title)
end
def create_description_change_note(issuable)
SystemNoteService.change_description(issuable, issuable.project, current_user)
end
def create_branch_change_note(issuable, branch_type, old_branch, new_branch)
SystemNoteService.change_branch(
issuable, issuable.project, current_user, branch_type,
old_branch, new_branch)
end
def create_task_status_note(issuable)
issuable.updated_tasks.each do |task|
SystemNoteService.change_task_status(issuable, issuable.project, current_user, task)
end
end
def create_time_estimate_note(issuable)
SystemNoteService.change_time_estimate(issuable, issuable.project, current_user)
end
def create_time_spent_note(issuable)
SystemNoteService.change_time_spent(issuable, issuable.project, issuable.time_spent_user)
end
def create_discussion_lock_note(issuable)
SystemNoteService.discussion_lock(issuable, current_user)
end
def filter_params(issuable)
ability_name = :"admin_#{issuable.to_ability_name}"
unless can?(current_user, ability_name, project)
unless can?(current_user, ability_name, issuable)
params.delete(:milestone_id)
params.delete(:labels)
params.delete(:add_label_ids)
......@@ -235,15 +189,14 @@ class IssuableBaseService < BaseService
# We have to perform this check before saving the issuable as Rails resets
# the changed fields upon calling #save.
update_project_counters = issuable.update_project_counter_caches?
update_project_counters = issuable.project && issuable.update_project_counter_caches?
if issuable.with_transaction_returning_status { issuable.save }
# We do not touch as it will affect a update on updated_at field
ActiveRecord::Base.no_touching do
handle_common_system_notes(issuable, old_labels: old_labels)
Issuable::CommonSystemNotesService.new(project, current_user).execute(issuable, old_labels)
end
change_discussion_lock(issuable)
handle_changes(
issuable,
old_labels: old_labels,
......@@ -302,12 +255,6 @@ class IssuableBaseService < BaseService
end
end
def change_discussion_lock(issuable)
if issuable.previous_changes.include?('discussion_locked')
create_discussion_lock_note(issuable)
end
end
def toggle_award(issuable)
award = params.delete(:emoji_award)
if award
......@@ -330,35 +277,17 @@ class IssuableBaseService < BaseService
attrs_changed || labels_changed || assignees_changed
end
def handle_common_system_notes(issuable, old_labels: [])
if issuable.previous_changes.include?('title')
create_title_change_note(issuable, issuable.previous_changes['title'].first)
end
if issuable.previous_changes.include?('description')
if issuable.tasks? && issuable.updated_tasks.any?
create_task_status_note(issuable)
else
# TODO: Show this note if non-task content was modified.
# https://gitlab.com/gitlab-org/gitlab-ce/issues/33577
create_description_change_note(issuable)
end
end
if issuable.previous_changes.include?('time_estimate')
create_time_estimate_note(issuable)
end
if issuable.time_spent?
create_time_spent_note(issuable)
end
create_labels_note(issuable, old_labels) if issuable.labels != old_labels
end
def invalidate_cache_counts(issuable, users: [])
users.each do |user|
user.public_send("invalidate_#{issuable.model_name.singular}_cache_counts") # rubocop:disable GitlabSecurity/PublicSend
end
end
# override if needed
def handle_changes(issuable, options)
end
# override if needed
def execute_hooks(issuable, action = 'open', params = {})
end
end
......@@ -27,10 +27,6 @@ module Issues
todo_service.update_issue(issue, current_user, old_mentioned_users)
end
if issue.previous_changes.include?('milestone_id')
create_milestone_note(issue)
end
if issue.assignees != old_assignees
create_assignee_note(issue, old_assignees)
notification_service.reassigned_issue(issue, current_user, old_assignees)
......
......@@ -52,10 +52,6 @@ module MergeRequests
reset_approvals(merge_request)
end
if merge_request.previous_changes.include?('milestone_id')
create_milestone_note(merge_request)
end
if merge_request.previous_changes.include?('assignee_id')
create_assignee_note(merge_request)
notification_service.reassigned_merge_request(merge_request, current_user)
......@@ -131,5 +127,11 @@ module MergeRequests
end
end
end
def create_branch_change_note(issuable, branch_type, old_branch, new_branch)
SystemNoteService.change_branch(
issuable, issuable.project, current_user, branch_type,
old_branch, new_branch)
end
end
end
......@@ -34,7 +34,7 @@
%li{ class: [merge_request_button_visibility(@merge_request, true), 'js-close-item'] }
= link_to 'Close', merge_request_path(@merge_request, merge_request: { state_event: :close }), method: :put, title: 'Close merge request'
%li{ class: merge_request_button_visibility(@merge_request, false) }
= link_to 'Reopen', merge_request_path(@merge_request, merge_request: {state_event: :reopen }), method: :put, class: 'reopen-mr-link', title: 'Reopen merge request'
= link_to 'Reopen', merge_request_path(@merge_request, merge_request: { state_event: :reopen }), method: :put, class: 'reopen-mr-link', title: 'Reopen merge request'
- if can_update_merge_request
= link_to 'Edit', edit_project_merge_request_path(@project, @merge_request), class: "hidden-xs hidden-sm btn btn-grouped issuable-edit"
......
---
title: View/edit epic at group level
merge_request: 3126
author:
type: added
......@@ -55,6 +55,10 @@ module Gitlab
#{config.root}/ee/app/views
])
config.helpers_paths.push(*%W[
#{config.root}/ee/app/helpers
])
# Only load the plugins named here, in the order given (default is alphabetical).
# :all can be used as a placeholder for all plugins not explicitly named.
# config.plugins = [ :exception_notification, :ssl_requirement, :all ]
......
......@@ -81,6 +81,11 @@ constraints(GroupUrlConstrainer.new) do
## EE-specific
resources :billings, only: [:index]
resources :boards, only: [:index, :show, :create, :update, :destroy]
resources :epics do
member do
get :realtime_changes
end
end
end
## EE-specific
......
......@@ -40,6 +40,7 @@ var config = {
diff_notes: './diff_notes/diff_notes_bundle.js',
environments: './environments/environments_bundle.js',
environments_folder: './environments/folder/environments_folder_bundle.js',
epic_show: 'ee/epics/epic_show/epic_show_bundle.js',
filtered_search: './filtered_search/filtered_search_bundle.js',
graphs: './graphs/graphs_bundle.js',
graphs_charts: './graphs/graphs_charts.js',
......
Gitlab::Seeder.quiet do
Group.all.each do |group|
5.times do
epic_params = {
title: FFaker::Lorem.sentence(6),
description: FFaker::Lorem.paragraphs(3).join("\n\n"),
author: group.users.sample,
group: group
}
Epic.create!(epic_params)
print '.'
end
end
end
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
class CreateEpics < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
create_table :epics do |t|
t.references :milestone, index: { name: 'index_milestone' }, foreign_key: { on_delete: :nullify }
t.integer :group_id, null: false, index: true
t.integer :author_id, null: false, index: true
t.integer :assignee_id, index: true
t.integer :iid, null: false, index: true
t.integer :cached_markdown_version, limit: 4
t.integer :updated_by_id
t.integer :last_edited_by_id
t.integer :lock_version
t.date :start_date
t.date :end_date
t.datetime_with_timezone :last_edited_at
t.timestamps_with_timezone
t.string :title, null: false
t.string :title_html, null: false
t.text :description
t.text :description_html
end
add_concurrent_foreign_key :epics, :namespaces, column: :group_id
add_concurrent_foreign_key :epics, :users, column: :author_id
add_concurrent_foreign_key :epics, :users, column: :assignee_id, on_delete: :nullify
create_table :epic_metrics do |t|
t.references :epic, index: { name: "index_epic_metrics" }, foreign_key: { on_delete: :cascade }, null: false
t.timestamps_with_timezone
end
end
def down
remove_foreign_key :epics, column: :group_id
remove_foreign_key :epics, column: :author_id
remove_foreign_key :epics, column: :assignee_id
remove_foreign_key :epics, column: :milestone_id
remove_foreign_key :epic_metrics, column: :epic_id
drop_table :epics
drop_table :epic_metrics
end
end
......@@ -640,6 +640,41 @@ ActiveRecord::Schema.define(version: 20171017145932) 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_metrics", force: :cascade do |t|
t.integer "epic_id", null: false
t.datetime_with_timezone "created_at", null: false
t.datetime_with_timezone "updated_at", null: false
end
add_index "epic_metrics", ["epic_id"], name: "index_epic_metrics", using: :btree
create_table "epics", force: :cascade do |t|
t.integer "milestone_id"
t.integer "group_id", null: false
t.integer "author_id", null: false
t.integer "assignee_id"
t.integer "iid", null: false
t.integer "cached_markdown_version"
t.integer "updated_by_id"
t.integer "last_edited_by_id"
t.integer "lock_version"
t.date "start_date"
t.date "end_date"
t.datetime_with_timezone "last_edited_at"
t.datetime_with_timezone "created_at", null: false
t.datetime_with_timezone "updated_at", null: false
t.string "title", null: false
t.string "title_html", null: false
t.text "description"
t.text "description_html"
end
add_index "epics", ["assignee_id"], name: "index_epics_on_assignee_id", using: :btree
add_index "epics", ["author_id"], name: "index_epics_on_author_id", using: :btree
add_index "epics", ["group_id"], name: "index_epics_on_group_id", using: :btree
add_index "epics", ["iid"], name: "index_epics_on_iid", using: :btree
add_index "epics", ["milestone_id"], name: "index_milestone", using: :btree
create_table "events", force: :cascade do |t|
t.integer "project_id"
t.integer "author_id", null: false
......@@ -2193,6 +2228,11 @@ ActiveRecord::Schema.define(version: 20171017145932) 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_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
add_foreign_key "epics", "users", column: "assignee_id", name: "fk_dccd3f98fc", on_delete: :nullify
add_foreign_key "epics", "users", column: "author_id", name: "fk_3654b61b03", on_delete: :cascade
add_foreign_key "events", "projects", on_delete: :cascade
add_foreign_key "events", "users", column: "author_id", name: "fk_edfd187b6f", on_delete: :cascade
add_foreign_key "fork_network_members", "fork_networks", on_delete: :cascade
......
<script>
import userAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import timeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import tooltip from '~/vue_shared/directives/tooltip';
export default {
name: 'epicHeader',
props: {
author: {
type: Object,
required: true,
validator: value => value.url && value.username && value.name,
},
created: {
type: String,
required: true,
},
},
directives: {
tooltip,
},
components: {
userAvatarLink,
timeagoTooltip,
},
};
</script>
<template>
<div class="detail-page-header">
Opened
<timeagoTooltip
:time="created"
/>
by
<strong>
<user-avatar-link
:link-href="author.url"
:img-src="author.src"
:img-size="24"
:tooltipText="author.username"
:username="author.name"
imgCssClasses="avatar-inline"
/>
</strong>
</div>
</template>
<script>
import issuableApp from '~/issue_show/components/app.vue';
import epicHeader from './epic_header.vue';
export default {
name: 'epicShowApp',
props: {
endpoint: {
type: String,
required: true,
},
canUpdate: {
required: true,
type: Boolean,
},
canDestroy: {
required: true,
type: Boolean,
},
markdownPreviewPath: {
type: String,
required: true,
},
markdownDocsPath: {
type: String,
required: true,
},
groupPath: {
type: String,
required: true,
},
initialTitleHtml: {
type: String,
required: true,
},
initialTitleText: {
type: String,
required: true,
},
initialDescriptionHtml: {
type: String,
required: false,
default: '',
},
initialDescriptionText: {
type: String,
required: false,
default: '',
},
created: {
type: String,
required: true,
},
author: {
type: Object,
required: true,
},
},
components: {
epicHeader,
issuableApp,
},
created() {
// Epics specific configuration
this.issuableRef = '';
this.projectPath = this.groupPath;
this.projectNamespace = '';
},
};
</script>
<template>
<div>
<epic-header
:author="author"
:created="created"
/>
<div class="issuable-details detail-page-description content-block">
<issuable-app
:can-update="canUpdate"
:can-destroy="canDestroy"
:endpoint="endpoint"
:issuable-ref="issuableRef"
:initial-title-html="initialTitleHtml"
:initial-title-text="initialTitleText"
:initial-description-html="initialDescriptionHtml"
:initial-description-text="initialDescriptionText"
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
:project-path="projectPath"
:project-namespace="projectNamespace"
:show-inline-edit-button="true"
/>
</div>
</div>
</template>
import Vue from 'vue';
import EpicShowApp from './components/epic_show_app.vue';
document.addEventListener('DOMContentLoaded', () => {
const el = document.querySelector('#epic-show-app');
const metaData = JSON.parse(el.dataset.meta);
const initialData = JSON.parse(el.dataset.initial);
const props = Object.assign({}, initialData, metaData, {
// Current iteration does not enable users
// to delete epics
canDestroy: false,
});
return new Vue({
el,
components: {
'epic-show-app': EpicShowApp,
},
render: createElement => createElement('epic-show-app', {
props,
}),
});
});
class Groups::EpicsController < Groups::ApplicationController
include IssuableActions
before_action :epic
before_action :authorize_update_issuable!, only: :update
skip_before_action :labels
private
def epic
@issuable = @epic ||= @group.epics.find_by(iid: params[:id])
return render_404 unless can?(current_user, :read_epic, @epic)
@epic
end
alias_method :issuable, :epic
def epic_params
params.require(:epic).permit(*epic_params_attributes)
end
def epic_params_attributes
%i[
title
description
start_date
end_date
]
end
def serializer
EpicSerializer.new(current_user: current_user)
end
def update_service
Epics::UpdateService.new(nil, current_user, epic_params)
end
def show_view
'groups/ee/epics/show'
end
end
......@@ -23,5 +23,9 @@ module EE
geo_primary_http_url_to_repo(project)
end
end
def epic_path(entity, *args)
group_epic_path(entity.group, entity, *args)
end
end
end
module EpicsHelper
def epic_meta_data
author = @epic.author
data = {
created: @epic.created_at,
author: {
name: author.name,
url: user_path(author),
username: "@#{author.username}",
src: avatar_icon(@epic.author)
}
}
data.to_json
end
end
module EE
module Epic
extend ActiveSupport::Concern
prepended do
include InternalId
include Issuable
include Noteable
belongs_to :assignee, class_name: "User"
belongs_to :group
validates :group, presence: true
end
def assignees
Array(assignee)
end
def project
nil
end
def supports_weight?
false
end
end
end
......@@ -8,6 +8,7 @@ module EE
included do
has_many :boards
has_many :epics
state_machine :ldap_sync_status, namespace: :ldap_sync, initial: :ready do
state :ready
......
module EE
module Note
extend ActiveSupport::Concern
def for_epic?
noteable.is_a?(Epic)
end
def for_project_noteable?
!for_epic? && super
end
end
end
......@@ -21,6 +21,8 @@ module EE
delegate :shared_runners_minutes_limit, :shared_runners_minutes_limit=,
to: :namespace
has_many :epics, foreign_key: :author_id
has_many :assigned_epics, foreign_key: :assignee_id, class_name: "Epic"
has_many :path_locks, dependent: :destroy # rubocop: disable Cop/ActiveRecordDependent
has_many :approvals, dependent: :destroy # rubocop: disable Cop/ActiveRecordDependent
......
class Epic::Metrics < ActiveRecord::Base
belongs_to :epic
def record!
self.save
end
end
......@@ -17,6 +17,19 @@ module EE
.allow_group_owners_to_manage_ldap
end
rule { public_group }.enable :read_epic
rule { logged_in_viewable }.enable :read_epic
rule { guest }.enable :read_epic
rule { reporter }.policy do
enable :create_epic
enable :admin_epic
enable :update_epic
enable :destroy_epic
end
rule { auditor }.enable :read_group
rule { admin | (can_owners_manage_ldap & owner) }.enable :admin_ldap_group_links
......
class EpicPolicy < BasePolicy
delegate { @subject.group }
end
class EpicEntity < IssuableEntity
expose :group_id
expose :start_date
expose :end_date
expose :web_url do |epic|
group_epic_path(epic.group, epic)
end
end
class EpicSerializer < BaseSerializer
entity EpicEntity
end
module Epics
class UpdateService < ::IssuableBaseService
def execute(epic)
update(epic)
end
end
end
- @no_breadcrumb_container = false
- @no_container = false
- @content_class = "limit-container-width" unless fluid_layout
-# TODO: Move this to @epic.to_reference when implementing gitlab-ee#3853
- epic_reference = "&#{@epic.iid}"
- add_to_breadcrumbs _("Epics"), group_epics_path(@group)
- breadcrumb_title epic_reference
- page_title "#{@epic.title} (#{epic_reference})", _("Epics")
- page_description @epic.description
- page_card_attributes @epic.card_attributes
- content_for :page_specific_javascripts do
= webpack_bundle_tag 'common_vue'
= webpack_bundle_tag 'epic_show'
#epic-show-app{ data: { initial: issuable_initial_data(@epic), meta: epic_meta_data } }
......@@ -95,7 +95,7 @@ module Banzai
end
def call
return doc if project.nil?
return doc unless project || group
ref_pattern = object_class.reference_pattern
link_pattern = object_class.link_reference_pattern
......@@ -288,10 +288,14 @@ module Banzai
end
def current_project_path
return unless project
@current_project_path ||= project.full_path
end
def current_project_namespace_path
return unless project
@current_project_namespace_path ||= project.namespace.full_path
end
......
......@@ -55,6 +55,10 @@ module Banzai
context[:project]
end
def group
context[:group]
end
def skip_project_check?
context[:skip_project_check]
end
......
......@@ -24,7 +24,7 @@ module Banzai
end
def call
return doc if project.nil? && !skip_project_check?
return doc if project.nil? && group.nil? && !skip_project_check?
ref_pattern = User.reference_pattern
ref_pattern_start = /\A#{ref_pattern}\z/
......@@ -101,19 +101,12 @@ module Banzai
end
def link_to_all(link_content: nil)
project = context[:project]
author = context[:author]
if author && !project.team.member?(author)
if author && !team_member?(author)
link_content
else
url = urls.project_url(project,
only_path: context[:only_path])
data = data_attribute(project: project.id, author: author.try(:id))
content = link_content || User.reference_prefix + 'all'
link_tag(url, data, content, 'All Project and Group Members')
parent_url(link_content, author)
end
end
......@@ -144,6 +137,35 @@ module Banzai
def link_tag(url, data, link_content, title)
%(<a href="#{url}" #{data} class="#{link_class}" title="#{escape_once(title)}">#{link_content}</a>)
end
def parent
context[:project] || context[:group]
end
def parent_group?
parent.is_a?(Group)
end
def team_member?(user)
if parent_group?
parent.member?(user)
else
parent.team.member?(user)
end
end
def parent_url(link_content, author)
if parent_group?
url = urls.group_url(parent, only_path: context[:only_path])
data = data_attribute(group: group.id, author: author.try(:id))
else
url = urls.project_url(parent, only_path: context[:only_path])
data = data_attribute(project: project.id, author: author.try(:id))
end
content = link_content || User.reference_prefix + 'all'
link_tag(url, data, content, 'All Project and Group Members')
end
end
end
end
......@@ -248,6 +248,45 @@ describe Projects::IssuesController do
end
end
describe 'PUT #update' do
subject do
put :update,
namespace_id: project.namespace,
project_id: project,
id: issue.to_param,
issue: { title: 'New title' }, format: :json
end
before do
sign_in(user)
end
context 'when user has access to update issue' do
before do
project.add_developer(user)
end
it 'updates the issue' do
subject
expect(response).to have_http_status(:ok)
expect(issue.reload.title).to eq('New title')
end
end
context 'when user does not have access to update issue' do
before do
project.add_guest(user)
end
it 'responds with 404' do
subject
expect(response).to have_http_status(:not_found)
end
end
end
describe 'Confidential Issues' do
let(:project) { create(:project_empty_repo, :public) }
let(:assignee) { create(:assignee) }
......
......@@ -186,17 +186,23 @@ describe Projects::MergeRequestsController do
end
describe 'PUT update' do
def update_merge_request(mr_params, additional_params = {})
params = {
namespace_id: project.namespace,
project_id: project,
id: merge_request.iid,
merge_request: mr_params
}.merge(additional_params)
put :update, params
end
context 'changing the assignee' do
it 'limits the attributes exposed on the assignee' do
assignee = create(:user)
project.add_developer(assignee)
put :update,
namespace_id: project.namespace.to_param,
project_id: project,
id: merge_request.iid,
merge_request: { assignee_id: assignee.id },
format: :json
update_merge_request({ assignee_id: assignee.id }, format: :json)
body = JSON.parse(response.body)
expect(body['assignee'].keys)
......@@ -204,6 +210,20 @@ describe Projects::MergeRequestsController do
end
end
context 'when user does not have access to update issue' do
before do
reporter = create(:user)
project.add_reporter(reporter)
sign_in(reporter)
end
it 'responds with 404' do
update_merge_request(title: 'New title')
expect(response).to have_http_status(:not_found)
end
end
context 'there is no source project' do
let(:project) { create(:project, :repository) }
let(:forked_project) { fork_project_with_submodules(project) }
......@@ -214,13 +234,7 @@ describe Projects::MergeRequestsController do
end
it 'closes MR without errors' do
post :update,
namespace_id: project.namespace,
project_id: project,
id: merge_request.iid,
merge_request: {
state_event: 'close'
}
update_merge_request(state_event: 'close')
expect(response).to redirect_to([merge_request.target_project.namespace.becomes(Namespace), merge_request.target_project, merge_request])
expect(merge_request.reload.closed?).to be_truthy
......@@ -229,13 +243,7 @@ describe Projects::MergeRequestsController do
it 'allows editing of a closed merge request' do
merge_request.close!
put :update,
namespace_id: project.namespace,
project_id: project,
id: merge_request.iid,
merge_request: {
title: 'New title'
}
update_merge_request(title: 'New title')
expect(response).to redirect_to([merge_request.target_project.namespace.becomes(Namespace), merge_request.target_project, merge_request])
expect(merge_request.reload.title).to eq 'New title'
......@@ -244,13 +252,7 @@ describe Projects::MergeRequestsController do
it 'does not allow to update target branch closed merge request' do
merge_request.close!
put :update,
namespace_id: project.namespace,
project_id: project,
id: merge_request.iid,
merge_request: {
target_branch: 'new_branch'
}
update_merge_request(target_branch: 'new_branch')
expect { merge_request.reload.target_branch }.not_to change { merge_request.target_branch }
end
......
require 'spec_helper'
describe Groups::EpicsController do
let(:group) { create(:group, :private) }
let(:epic) { create(:epic, group: group) }
let(:user) { create(:user) }
before do
sign_in(user)
end
describe 'GET #show' do
def show_epic(format = :html)
get :show, group_id: group, id: epic.to_param, format: format
end
context 'when format is HTML' do
it 'renders template' do
group.add_developer(user)
show_epic
expect(response.content_type).to eq 'text/html'
expect(response).to render_template 'groups/ee/epics/show'
end
context 'with unauthorized user' do
it 'returns a not found 404 response' do
show_epic
expect(response).to have_http_status(404)
expect(response.content_type).to eq 'text/html'
end
end
end
context 'when format is JSON' do
it 'returns epic' do
group.add_developer(user)
show_epic(:json)
expect(response).to have_http_status(200)
expect(response).to match_response_schema('entities/epic')
end
context 'with unauthorized user' do
it 'returns a not found 404 response' do
show_epic(:json)
expect(response).to have_http_status(404)
expect(response.content_type).to eq 'application/json'
end
end
end
end
describe 'PUT #update' do
before do
group.add_user(user, :developer)
put :update, group_id: group, id: epic.to_param, epic: { title: 'New title' }, format: :json
end
it 'returns status 200' do
expect(response.status).to eq(200)
end
it 'updates the epic correctly' do
expect(epic.reload.title).to eq('New title')
end
end
describe 'GET #realtime_changes' do
subject { get :realtime_changes, group_id: group, id: epic.to_param }
it 'returns epic' do
group.add_user(user, :developer)
subject
expect(response.content_type).to eq 'application/json'
expect(JSON.parse(response.body)).to include('title_text', 'title', 'description', 'description_text')
end
context 'with unauthorized user' do
it 'returns a not found 404 response' do
subject
expect(response).to have_http_status(404)
end
end
end
end
require 'spec_helper'
feature 'Update Epic', :js do
let(:user) { create(:user) }
let(:group) { create(:group, :public) }
let(:epic) { create(:epic, group: group) }
before do
sign_in(user)
end
context 'when user who is not a group member displays the epic' do
it 'does not show the Edit button' do
visit group_epic_path(group, epic)
expect(page).not_to have_selector('.btn-edit')
end
end
context 'when user with developer access displays the epic' do
before do
group.add_developer(user)
visit group_epic_path(group, epic)
wait_for_requests
end
it 'updates the issue' do
find('.btn-edit').trigger('click')
fill_in 'issuable-title', with: 'New epic title'
fill_in 'issue-description', with: 'New epic description'
click_button 'Save changes'
expect(find('.issuable-details h2.title')).to have_content('New epic title')
expect(find('.issuable-details .description')).to have_content('New epic description')
end
end
end
require 'spec_helper'
describe EpicsHelper do
include ApplicationHelper
describe '#epic_meta_data' do
it 'returns the correct json' do
user = create(:user)
@epic = create(:epic, author: user)
expect(JSON.parse(epic_meta_data).keys).to match_array(%w[created author])
expect(JSON.parse(epic_meta_data)['author']).to eq({
'name' => user.name,
'url' => "/#{user.username}",
'username' => "@#{user.username}",
'src' => "#{avatar_icon(user)}"
})
end
end
end
require 'spec_helper'
describe Epic do
describe 'associations' do
subject { build(:epic) }
it { is_expected.to belong_to(:author).class_name('User') }
it { is_expected.to belong_to(:group) }
end
describe 'validations' do
subject { create(:epic) }
it { is_expected.to validate_presence_of(:group) }
it { is_expected.to validate_presence_of(:author) }
it { is_expected.to validate_presence_of(:title) }
end
describe 'modules' do
subject { described_class }
it { is_expected.to include_module(InternalId) }
end
end
require 'spec_helper'
describe EpicPolicy do
let(:user) { create(:user) }
def permissions(user, group)
epic = create(:epic, group: group)
described_class.new(user, epic)
end
context 'when an epic is in a private group' do
let(:group) { create(:group, :private) }
it 'anonymous user can not read epics' do
expect(permissions(nil, group))
.to be_disallowed(:read_epic, :update_epic, :destroy_epic, :admin_epic, :create_epic)
end
it 'user who is not a group member can not read epics' do
expect(permissions(user, group))
.to be_disallowed(:read_epic, :update_epic, :destroy_epic, :admin_epic, :create_epic)
end
it 'guest group member can only read epics' do
group.add_guest(user)
expect(permissions(user, group)).to be_allowed(:read_epic)
expect(permissions(user, group)).to be_disallowed(:update_epic, :destroy_epic, :admin_epic, :create_epic)
end
it 'reporter group member can manage epics' do
group.add_reporter(user)
expect(permissions(user, group))
.to be_allowed(:read_epic, :update_epic, :destroy_epic, :admin_epic, :create_epic)
end
end
context 'when an epic is in an internal group' do
let(:group) { create(:group, :internal) }
it 'anonymous user can not read epics' do
expect(permissions(nil, group))
.to be_disallowed(:read_epic, :update_epic, :destroy_epic, :admin_epic, :create_epic)
end
it 'user who is not a group member can only read epics' do
expect(permissions(user, group)).to be_allowed(:read_epic)
expect(permissions(user, group)).to be_disallowed(:update_epic, :destroy_epic, :admin_epic, :create_epic)
end
it 'guest group member can only read epics' do
group.add_guest(user)
expect(permissions(user, group)).to be_allowed(:read_epic)
expect(permissions(user, group)).to be_disallowed(:update_epic, :destroy_epic, :admin_epic, :create_epic)
end
it 'reporter group member can manage epics' do
group.add_reporter(user)
expect(permissions(user, group))
.to be_allowed(:read_epic, :update_epic, :destroy_epic, :admin_epic, :create_epic)
end
end
context 'when an epic is in a public group' do
let(:group) { create(:group, :public) }
it 'anonymous user can only read epics' do
expect(permissions(nil, group)).to be_allowed(:read_epic)
expect(permissions(nil, group)).to be_disallowed(:update_epic, :destroy_epic, :admin_epic, :create_epic)
end
it 'user who is not a group member can only read epics' do
expect(permissions(user, group)).to be_allowed(:read_epic)
expect(permissions(user, group)).to be_disallowed(:update_epic, :destroy_epic, :admin_epic, :create_epic)
end
it 'guest group member can only read epics' do
group.add_guest(user)
expect(permissions(user, group)).to be_allowed(:read_epic)
expect(permissions(user, group)).to be_disallowed(:update_epic, :destroy_epic, :admin_epic, :create_epic)
end
it 'reporter group member can manage epics' do
group.add_reporter(user)
expect(permissions(user, group))
.to be_allowed(:read_epic, :update_epic, :destroy_epic, :admin_epic, :create_epic)
end
end
end
require 'spec_helper'
describe EpicEntity do
let(:group) { create(:group) }
let(:resource) { create(:epic, group: group) }
let(:user) { create(:user) }
let(:request) { double('request', current_user: user) }
subject { described_class.new(resource, request: request).as_json }
it 'has Issuable attributes' do
expect(subject).to include(:id, :iid, :author_id, :description, :lock_version, :milestone_id,
:title, :updated_by_id, :created_at, :updated_at, :milestone, :labels)
end
it 'has epic specific attributes' do
expect(subject).to include(:start_date, :end_date, :group_id, :web_url)
end
end
require 'spec_helper'
describe Epics::UpdateService do
let(:group) { create(:group, :internal)}
let(:user) { create(:user) }
let(:epic) { create(:epic, group: group) }
describe '#execute' do
def update_epic(opts)
described_class.new(nil, user, opts).execute(epic)
end
context 'multiple values update' do
let(:opts) do
{
title: 'New title',
description: 'New description',
start_date: '2017-01-09',
end_date: '2017-10-21'
}
end
it 'updates the epic correctly' do
update_epic(opts)
expect(epic).to be_valid
expect(epic.title).to eq(opts[:title])
expect(epic.description).to eq(opts[:description])
expect(epic.start_date).to eq(Date.strptime(opts[:start_date]))
expect(epic.end_date).to eq(Date.strptime(opts[:end_date]))
end
it 'updates the last_edited_at value' do
expect { update_epic(opts) }.to change { epic.last_edited_at }
end
end
context 'when title has changed' do
it 'creates system note about title change' do
expect { update_epic(title: 'New title') }.to change { Note.count }.from(0).to(1)
note = Note.last
expect(note.note).to start_with('changed title')
expect(note.noteable).to eq(epic)
end
end
context 'when description has changed' do
it 'creates system note about description change' do
expect { update_epic(description: 'New description') }.to change { Note.count }.from(0).to(1)
note = Note.last
expect(note.note).to start_with('changed the description')
expect(note.noteable).to eq(epic)
end
end
end
end
FactoryGirl.define do
factory :epic do
title { generate(:title) }
group
author
end
end
{
"type": "object",
"properties" : {
"id": { "type": "integer" },
"iid": { "type": "integer" },
"author_id": { "type": "integer" },
"group_id": { "type": ["integer", "null"] },
"title": { "type": "string" },
"lock_version": { "type": ["string", "null"] },
"description": { "type": ["string" , "null"] },
"confidential": { "type": "boolean" },
"milestone_id": { "type": ["string", "null"] },
"state": { "type": "string" },
"start_date": { "type": ["date", "null"] },
"end_date": { "type": ["date", "null"] },
"updated_by_id": { "type": ["string", "null"] },
"created_at": { "type": "string" },
"updated_at": { "type": "string" },
"deleted_at": { "type": ["string", "null"] },
"web_url": { "type": "string" },
"milestone": { "type": ["object", "null"] },
"labels": { "type": ["array", "null"] },
"group": {
"id": { "type": "integer" },
"path": { "type": "string" }
},
"assignee": {
"id": { "type": "integer" },
"name": { "type": "string" },
"username": { "type": "string" },
"avatar_url": { "type": "uri" }
},
"assignees": {
"type": "array",
"items": {
"type": ["object", "null"],
"required": [
"id",
"name",
"username",
"avatar_url"
],
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" },
"username": { "type": "string" },
"avatar_url": { "type": "uri" }
}
}
}
},
"additionalProperties": false
}
......@@ -63,4 +63,30 @@ describe GitlabRoutingHelper do
it { expect(resend_invite_group_member_path(group_member)).to eq resend_invite_group_group_member_path(group_member.source, group_member) }
end
end
describe '#preview_markdown_path' do
let(:project) { create(:project) }
it 'returns group preview markdown path for a group parent' do
group = create(:group)
expect(preview_markdown_path(group)).to eq("/groups/#{group.path}/preview_markdown")
end
it 'returns project preview markdown path for a project parent' do
expect(preview_markdown_path(project)).to eq("/#{project.full_path}/preview_markdown")
end
it 'returns snippet preview markdown path for a personal snippet' do
@snippet = create(:personal_snippet)
expect(preview_markdown_path(nil)).to eq("/snippets/preview_markdown")
end
it 'returns project preview markdown path for a project snippet' do
@snippet = create(:project_snippet, project: project)
expect(preview_markdown_path(project)).to eq("/#{project.full_path}/preview_markdown")
end
end
end
......@@ -159,4 +159,58 @@ describe IssuablesHelper do
end
end
end
describe '#issuable_initial_data' do
let(:user) { create(:user) }
before do
allow(helper).to receive(:current_user).and_return(user)
allow(helper).to receive(:can?).and_return(true)
end
it 'returns the correct json for an issue' do
issue = create(:issue, author: user, description: 'issue text')
@project = issue.project
expected_data = {
'endpoint' => "/#{@project.full_path}/issues/#{issue.iid}",
'canUpdate' => true,
'canDestroy' => true,
'issuableRef' => "##{issue.iid}",
'markdownPreviewPath' => "/#{@project.full_path}/preview_markdown",
'markdownDocsPath' => '/help/user/markdown',
'issuableTemplates' => [],
'projectPath' => @project.path,
'projectNamespace' => @project.namespace.path,
'initialTitleHtml' => issue.title,
'initialTitleText' => issue.title,
'initialDescriptionHtml' => '<p dir="auto">issue text</p>',
'initialDescriptionText' => 'issue text',
'initialTaskStatus' => '0 of 0 tasks completed'
}
expect(JSON.parse(helper.issuable_initial_data(issue))).to eq(expected_data)
end
it 'returns the correct json for an epic' do
epic = create(:epic, author: user, description: 'epic text')
@group = epic.group
expected_data = {
'endpoint' => "/groups/#{@group.full_path}/-/epics/#{epic.iid}",
'canUpdate' => true,
'canDestroy' => true,
'issuableRef' => nil,
'markdownPreviewPath' => "/groups/#{@group.full_path}/preview_markdown",
'markdownDocsPath' => '/help/user/markdown',
'issuableTemplates' => nil,
'groupPath' => @group.path,
'initialTitleHtml' => epic.title,
'initialTitleText' => epic.title,
'initialDescriptionHtml' => '<p dir="auto">epic text</p>',
'initialDescriptionText' => 'epic text',
'initialTaskStatus' => '0 of 0 tasks completed'
}
expect(JSON.parse(helper.issuable_initial_data(epic))).to eq(expected_data)
end
end
end
import Vue from 'vue';
import epicHeader from 'ee/epics/epic_show/components/epic_header.vue';
import mountComponent from '../../../helpers/vue_mount_component_helper';
import { headerProps } from '../mock_data';
describe('epicHeader', () => {
let vm;
const { author } = headerProps;
beforeEach(() => {
const EpicHeader = Vue.extend(epicHeader);
vm = mountComponent(EpicHeader, headerProps);
});
it('should render timeago tooltip', () => {
expect(vm.$el.querySelector('time')).toBeDefined();
});
it('should link to author url', () => {
expect(vm.$el.querySelector('a').href).toEqual(author.url);
});
it('should render author avatar', () => {
expect(vm.$el.querySelector('img').src).toEqual(author.src);
});
it('should render author name', () => {
expect(vm.$el.querySelector('.user-avatar-link').innerText.trim()).toEqual(author.name);
});
it('should render username tooltip', () => {
expect(vm.$el.querySelector('.user-avatar-link span').dataset.originalTitle).toEqual(author.username);
});
});
import Vue from 'vue';
import epicShowApp from 'ee/epics/epic_show/components/epic_show_app.vue';
import epicHeader from 'ee/epics/epic_show/components/epic_header.vue';
import issuableApp from '~/issue_show/components/app.vue';
import mountComponent from '../../../helpers/vue_mount_component_helper';
import { props } from '../mock_data';
import issueShowData from '../../../issue_show/mock_data';
describe('EpicShowApp', () => {
let vm;
let headerVm;
let issuableAppVm;
const interceptor = (request, next) => {
next(request.respondWith(JSON.stringify(issueShowData.initialRequest), {
status: 200,
}));
};
beforeEach(() => {
Vue.http.interceptors.push(interceptor);
const {
canUpdate,
canDestroy,
endpoint,
initialTitleHtml,
initialTitleText,
markdownPreviewPath,
markdownDocsPath,
author,
created,
} = props;
const EpicShowApp = Vue.extend(epicShowApp);
vm = mountComponent(EpicShowApp, props);
const EpicHeader = Vue.extend(epicHeader);
headerVm = mountComponent(EpicHeader, {
author,
created,
});
const IssuableApp = Vue.extend(issuableApp);
issuableAppVm = mountComponent(IssuableApp, {
canUpdate,
canDestroy,
endpoint,
issuableRef: '',
initialTitleHtml,
initialTitleText,
initialDescriptionHtml: '',
initialDescriptionText: '',
markdownPreviewPath,
markdownDocsPath,
projectPath: props.groupPath,
projectNamespace: '',
showInlineEditButton: true,
});
});
afterEach(() => {
Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor);
});
it('should render epic-header', () => {
expect(vm.$el.innerHTML.indexOf(headerVm.$el.innerHTML) !== -1).toEqual(true);
});
it('should render issuable-app', () => {
expect(vm.$el.innerHTML.indexOf(issuableAppVm.$el.innerHTML) !== -1).toEqual(true);
});
});
export const contentProps = {
endpoint: '',
canUpdate: true,
canDestroy: false,
markdownPreviewPath: '',
markdownDocsPath: '',
groupPath: '',
initialTitleHtml: '',
initialTitleText: '',
};
export const headerProps = {
author: {
url: `${gl.TEST_HOST}/url`,
src: `${gl.TEST_HOST}/image`,
username: '@root',
name: 'Administrator',
},
created: (new Date()).toISOString(),
};
export const props = Object.assign({}, contentProps, headerProps);
......@@ -74,7 +74,7 @@ describe('Title component', () => {
});
});
describe('inline edit button', () => {
describe('show inline edit button', () => {
beforeEach(() => {
spyOn(eventHub, '$emit');
});
......
......@@ -317,6 +317,68 @@ describe Banzai::Filter::IssueReferenceFilter do
end
end
context 'group context' do
let(:group) { create(:group) }
let(:context) { { project: nil, group: group } }
it 'ignores shorthanded issue reference' do
reference = "##{issue.iid}"
text = "Fixed #{reference}"
expect(reference_filter(text, context).to_html).to eq(text)
end
it 'ignores valid references when cross-reference project uses external tracker' do
expect_any_instance_of(described_class).to receive(:find_object)
.with(project, issue.iid)
.and_return(nil)
reference = "#{project.full_path}##{issue.iid}"
text = "Issue #{reference}"
expect(reference_filter(text, context).to_html).to eq(text)
end
it 'links to a valid reference for complete cross-reference' do
reference = "#{project.full_path}##{issue.iid}"
doc = reference_filter("See #{reference}", context)
expect(doc.css('a').first.attr('href')).to eq helper.url_for_issue(issue.iid, project)
end
it 'ignores reference for shorthand cross-reference' do
reference = "#{project.path}##{issue.iid}"
text = "See #{reference}"
expect(reference_filter(text, context).to_html).to eq(text)
end
it 'links to a valid reference for url cross-reference' do
reference = helper.url_for_issue(issue.iid, project) + "#note_123"
doc = reference_filter("See #{reference}", context)
expect(doc.css('a').first.attr('href')).to eq(helper.url_for_issue(issue.iid, project) + "#note_123")
end
it 'links to a valid reference for cross-reference in link href' do
reference = "#{helper.url_for_issue(issue.iid, project) + "#note_123"}"
reference_link = %{<a href="#{reference}">Reference</a>}
doc = reference_filter("See #{reference_link}", context)
expect(doc.css('a').first.attr('href')).to eq helper.url_for_issue(issue.iid, project) + "#note_123"
end
it 'links to a valid reference for issue reference in the link href' do
reference = issue.to_reference(group)
reference_link = %{<a href="#{reference}">Reference</a>}
doc = reference_filter("See #{reference_link}", context)
expect(doc.css('a').first.attr('href')).to eq helper.url_for_issue(issue.iid, project)
end
end
describe '#issues_per_project' do
context 'using an internal issue tracker' do
it 'returns a Hash containing the issues per project' do
......
......@@ -594,4 +594,16 @@ describe Banzai::Filter::LabelReferenceFilter do
expect(reference_filter(act).to_html).to eq exp
end
end
describe 'group context' do
it 'points to referenced project issues page' do
project = create(:project)
label = create(:label, project: project)
reference = "#{project.full_path}~#{label.name}"
result = reference_filter("See #{reference}", { project: nil, group: create(:group) } )
expect(result.css('a').first.attr('href')).to eq(urls.project_issues_url(project, label_name: label.name))
end
end
end
......@@ -214,4 +214,14 @@ describe Banzai::Filter::MergeRequestReferenceFilter do
expect(doc.to_html).to match(/\(<a.+>#{Regexp.escape(merge.to_reference(project))} \(diffs, comment 123\)<\/a>\.\)/)
end
end
context 'group context' do
it 'links to a valid reference' do
reference = "#{project.full_path}!#{merge.iid}"
result = reference_filter("See #{reference}", { project: nil, group: create(:group) } )
expect(result.css('a').first.attr('href')).to eq(urls.project_merge_request_url(project, merge))
end
end
end
......@@ -343,4 +343,15 @@ describe Banzai::Filter::MilestoneReferenceFilter do
expect(doc.css('a')).to be_empty
end
end
context 'group context' do
it 'links to a valid reference' do
milestone = create(:milestone, project: project)
reference = "#{project.full_path}%#{milestone.iid}"
result = reference_filter("See #{reference}", { project: nil, group: create(:group) } )
expect(result.css('a').first.attr('href')).to eq(urls.milestone_url(milestone))
end
end
end
......@@ -201,4 +201,14 @@ describe Banzai::Filter::SnippetReferenceFilter do
expect(reference_filter(act).to_html).to match(/<a.+>#{Regexp.escape(invalidate_reference(reference))}<\/a>/)
end
end
context 'group context' do
it 'links to a valid reference' do
reference = "#{project.full_path}$#{snippet.id}"
result = reference_filter("See #{reference}", { project: nil, group: create(:group) } )
expect(result.css('a').first.attr('href')).to eq(urls.project_snippet_url(project, snippet))
end
end
end
......@@ -208,6 +208,39 @@ describe Banzai::Filter::UserReferenceFilter do
end
end
context 'in group context' do
let(:group) { create(:group) }
let(:group_member) { create(:user) }
before do
group.add_developer(group_member)
end
let(:context) { { author: group_member, project: nil, group: group } }
it 'supports a special @all mention' do
reference = User.reference_prefix + 'all'
doc = reference_filter("Hey #{reference}", context)
expect(doc.css('a').length).to eq(1)
expect(doc.css('a').first.attr('href')).to eq urls.group_url(group)
end
it 'supports mentioning a single user' do
reference = group_member.to_reference
doc = reference_filter("Hey #{reference}", context)
expect(doc.css('a').first.attr('href')).to eq urls.user_url(group_member)
end
it 'supports mentioning a group' do
reference = group.to_reference
doc = reference_filter("Hey #{reference}", context)
expect(doc.css('a').first.attr('href')).to eq urls.user_url(group)
end
end
describe '#namespaces' do
it 'returns a Hash containing all Namespaces' do
document = Nokogiri::HTML.fragment("<p>#{user.to_reference}</p>")
......
......@@ -154,18 +154,6 @@ describe Gitlab::PathRegex do
end.uniq
end
let(:ee_paths_after_group_id) do
%w[
audit_events
analytics
ldap
ldap_group_links
notification_setting
pipeline_quota hooks
subgroups
]
end
describe 'TOP_LEVEL_ROUTES' do
it 'includes all the top level namespaces' do
failure_block = lambda do
......
......@@ -10,7 +10,7 @@ describe GroupPolicy do
let(:admin) { create(:admin) }
let(:group) { create(:group) }
let(:reporter_permissions) { [:admin_label] }
let(:reporter_permissions) { [:admin_label, :create_epic, :admin_epic] }
let(:developer_permissions) { [:admin_milestones] }
......
require 'spec_helper'
describe IssueEntity do
let(:project) { create(:project) }
let(:resource) { create(:issue, project: project) }
let(:user) { create(:user) }
let(:request) { double('request', current_user: user) }
subject { described_class.new(resource, request: request).as_json }
it 'has Issuable attributes' do
expect(subject).to include(:id, :iid, :author_id, :description, :lock_version, :milestone_id,
:title, :updated_by_id, :created_at, :updated_at, :milestone, :labels)
end
it 'has time estimation attributes' do
expect(subject).to include(:time_estimate, :total_time_spent, :human_time_estimate, :human_total_time_spent)
end
end
......@@ -30,8 +30,17 @@ describe MergeRequestEntity do
:assign_to_closing)
end
it 'has Issuable attributes' do
expect(subject).to include(:id, :iid, :author_id, :description, :lock_version, :milestone_id,
:title, :updated_by_id, :created_at, :updated_at, :milestone, :labels)
end
it 'has time estimation attributes' do
expect(subject).to include(:time_estimate, :total_time_spent, :human_time_estimate, :human_total_time_spent)
end
it 'has important MergeRequest attributes' do
expect(subject).to include(:diff_head_sha, :merge_commit_message,
expect(subject).to include(:state, :deleted_at, :diff_head_sha, :merge_commit_message,
:has_conflicts, :has_ci, :merge_path,
:conflict_resolution_path,
:cancel_merge_when_pipeline_succeeds_path,
......
require 'spec_helper'
describe Issuable::CommonSystemNotesService do
let(:user) { create(:user) }
let(:project) { create(:project) }
let(:issuable) { create(:issue) }
shared_examples 'system note creation' do |update_params, note_text|
subject { described_class.new(project, user).execute(issuable, [])}
before do
issuable.assign_attributes(update_params)
issuable.save
end
it 'creates 1 system note with the correct content' do
expect { subject }.to change { Note.count }.from(0).to(1)
note = Note.last
expect(note.note).to match(note_text)
expect(note.noteable_type).to eq('Issue')
end
end
describe '#execute' do
it_behaves_like 'system note creation', { title: 'New title' }, 'changed title'
it_behaves_like 'system note creation', { description: 'New description' }, 'changed the description'
it_behaves_like 'system note creation', { discussion_locked: true }, 'locked this issue'
it_behaves_like 'system note creation', { time_estimate: 5 }, 'changed time estimate'
context 'when new label is added' do
before do
label = create(:label, project: project)
issuable.labels << label
end
it_behaves_like 'system note creation', {}, /added ~\w+ label/
end
context 'when new milestone is assigned' do
before do
milestone = create(:milestone, project: project)
issuable.milestone_id = milestone.id
end
it_behaves_like 'system note creation', {}, 'changed milestone'
end
end
end
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