Commit 4787644f authored by Fatih Acet's avatar Fatih Acet

Merge branch 'ee-jprovazn-comment-thread' into 'master'

[EE] Epic comment thread

Closes gitlab-ce#42148

See merge request gitlab-org/gitlab-ee!4997
parents d0a8a2db 4b1d1a7e
...@@ -4,7 +4,7 @@ import $ from 'jquery'; ...@@ -4,7 +4,7 @@ import $ from 'jquery';
import _ from 'underscore'; import _ from 'underscore';
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
import { __ } from './locale'; import { __ } from './locale';
import { isInIssuePage, isInMRPage, hasVueMRDiscussionsCookie, updateTooltipTitle } from './lib/utils/common_utils'; import { isInIssuePage, isInMRPage, isInEpicPage, hasVueMRDiscussionsCookie, updateTooltipTitle } from './lib/utils/common_utils';
import flash from './flash'; import flash from './flash';
import axios from './lib/utils/axios_utils'; import axios from './lib/utils/axios_utils';
...@@ -300,7 +300,7 @@ class AwardsHandler { ...@@ -300,7 +300,7 @@ class AwardsHandler {
} }
isInVueNoteablePage() { isInVueNoteablePage() {
return isInIssuePage() || this.isVueMRDiscussions(); return isInIssuePage() || isInEpicPage() || this.isVueMRDiscussions();
} }
getVotesBlock() { getVotesBlock() {
......
...@@ -33,6 +33,7 @@ export const checkPageAndAction = (page, action) => { ...@@ -33,6 +33,7 @@ export const checkPageAndAction = (page, action) => {
export const isInIssuePage = () => checkPageAndAction('issues', 'show'); export const isInIssuePage = () => checkPageAndAction('issues', 'show');
export const isInMRPage = () => checkPageAndAction('merge_requests', 'show'); export const isInMRPage = () => checkPageAndAction('merge_requests', 'show');
export const isInEpicPage = () => checkPageAndAction('epics', 'show');
export const isInNoteablePage = () => isInIssuePage() || isInMRPage(); export const isInNoteablePage = () => isInIssuePage() || isInMRPage();
export const hasVueMRDiscussionsCookie = () => Cookies.get('vue_mr_discussions'); export const hasVueMRDiscussionsCookie = () => Cookies.get('vue_mr_discussions');
......
...@@ -99,6 +99,10 @@ export default { ...@@ -99,6 +99,10 @@ export default {
'js-note-target-reopen': !this.isOpen, 'js-note-target-reopen': !this.isOpen,
}; };
}, },
supportQuickActions() {
// Disable quick actions support for Epics
return this.noteableType !== constants.EPIC_NOTEABLE_TYPE;
},
markdownDocsPath() { markdownDocsPath() {
return this.getNotesData.markdownDocsPath; return this.getNotesData.markdownDocsPath;
}, },
...@@ -355,7 +359,7 @@ Please check your network connection and try again.`; ...@@ -355,7 +359,7 @@ Please check your network connection and try again.`;
name="note[note]" name="note[note]"
class="note-textarea js-vue-comment-form class="note-textarea js-vue-comment-form
js-gfm-input js-autosize markdown-area js-vue-textarea" js-gfm-input js-autosize markdown-area js-vue-textarea"
data-supports-quick-actions="true" :data-supports-quick-actions="supportQuickActions"
aria-label="Description" aria-label="Description"
v-model="note" v-model="note"
ref="textarea" ref="textarea"
......
...@@ -50,7 +50,11 @@ export default { ...@@ -50,7 +50,11 @@ export default {
...mapGetters(['notes', 'getNotesDataByProp', 'discussionCount']), ...mapGetters(['notes', 'getNotesDataByProp', 'discussionCount']),
noteableType() { noteableType() {
// FIXME -- @fatihacet Get this from JSON data. // FIXME -- @fatihacet Get this from JSON data.
const { ISSUE_NOTEABLE_TYPE, MERGE_REQUEST_NOTEABLE_TYPE } = constants; const { ISSUE_NOTEABLE_TYPE, MERGE_REQUEST_NOTEABLE_TYPE, EPIC_NOTEABLE_TYPE } = constants;
if (this.noteableData.noteableType === EPIC_NOTEABLE_TYPE) {
return EPIC_NOTEABLE_TYPE;
}
return this.noteableData.merge_params return this.noteableData.merge_params
? MERGE_REQUEST_NOTEABLE_TYPE ? MERGE_REQUEST_NOTEABLE_TYPE
......
...@@ -10,6 +10,7 @@ export const CLOSED = 'closed'; ...@@ -10,6 +10,7 @@ export const CLOSED = 'closed';
export const EMOJI_THUMBSUP = 'thumbsup'; export const EMOJI_THUMBSUP = 'thumbsup';
export const EMOJI_THUMBSDOWN = 'thumbsdown'; export const EMOJI_THUMBSDOWN = 'thumbsdown';
export const ISSUE_NOTEABLE_TYPE = 'issue'; export const ISSUE_NOTEABLE_TYPE = 'issue';
export const EPIC_NOTEABLE_TYPE = 'epic';
export const MERGE_REQUEST_NOTEABLE_TYPE = 'merge_request'; export const MERGE_REQUEST_NOTEABLE_TYPE = 'merge_request';
export const UNRESOLVE_NOTE_METHOD_NAME = 'delete'; export const UNRESOLVE_NOTE_METHOD_NAME = 'delete';
export const RESOLVE_NOTE_METHOD_NAME = 'post'; export const RESOLVE_NOTE_METHOD_NAME = 'post';
...@@ -12,8 +12,11 @@ document.addEventListener( ...@@ -12,8 +12,11 @@ document.addEventListener(
data() { data() {
const notesDataset = document.getElementById('js-vue-notes').dataset; const notesDataset = document.getElementById('js-vue-notes').dataset;
const parsedUserData = JSON.parse(notesDataset.currentUserData); const parsedUserData = JSON.parse(notesDataset.currentUserData);
const noteableData = JSON.parse(notesDataset.noteableData);
let currentUserData = {}; let currentUserData = {};
noteableData.noteableType = notesDataset.noteableType;
if (parsedUserData) { if (parsedUserData) {
currentUserData = { currentUserData = {
id: parsedUserData.id, id: parsedUserData.id,
...@@ -25,7 +28,7 @@ document.addEventListener( ...@@ -25,7 +28,7 @@ document.addEventListener(
} }
return { return {
noteableData: JSON.parse(notesDataset.noteableData), noteableData,
currentUserData, currentUserData,
notesData: JSON.parse(notesDataset.notesData), notesData: JSON.parse(notesDataset.notesData),
}; };
......
...@@ -14,6 +14,8 @@ export default { ...@@ -14,6 +14,8 @@ export default {
return constants.MERGE_REQUEST_NOTEABLE_TYPE; return constants.MERGE_REQUEST_NOTEABLE_TYPE;
case 'Issue': case 'Issue':
return constants.ISSUE_NOTEABLE_TYPE; return constants.ISSUE_NOTEABLE_TYPE;
case 'Epic':
return constants.EPIC_NOTEABLE_TYPE;
default: default:
return ''; return '';
} }
......
...@@ -88,11 +88,15 @@ module IssuableActions ...@@ -88,11 +88,15 @@ module IssuableActions
discussions = Discussion.build_collection(notes, issuable) discussions = Discussion.build_collection(notes, issuable)
render json: DiscussionSerializer.new(project: project, noteable: issuable, current_user: current_user).represent(discussions, context: self) render json: discussion_serializer.represent(discussions, context: self)
end end
private private
def discussion_serializer
DiscussionSerializer.new(project: project, noteable: issuable, current_user: current_user, note_entity: ProjectNoteEntity)
end
def recaptcha_check_if_spammable(should_redirect = true, &block) def recaptcha_check_if_spammable(should_redirect = true, &block)
return yield unless issuable.is_a? Spammable return yield unless issuable.is_a? Spammable
......
...@@ -212,7 +212,7 @@ module NotesActions ...@@ -212,7 +212,7 @@ module NotesActions
end end
def note_serializer def note_serializer
NoteSerializer.new(project: project, noteable: noteable, current_user: current_user) ProjectNoteSerializer.new(project: project, noteable: noteable, current_user: current_user)
end end
def note_project def note_project
......
...@@ -43,7 +43,7 @@ class Projects::DiscussionsController < Projects::ApplicationController ...@@ -43,7 +43,7 @@ class Projects::DiscussionsController < Projects::ApplicationController
def render_json_with_discussions_serializer def render_json_with_discussions_serializer
render json: render json:
DiscussionSerializer.new(project: project, noteable: discussion.noteable, current_user: current_user) DiscussionSerializer.new(project: project, noteable: discussion.noteable, current_user: current_user, note_entity: ProjectNoteEntity)
.represent(discussion, context: self) .represent(discussion, context: self)
end end
......
class NotesFinder class NotesFinder
prepend EE::NotesFinder
FETCH_OVERLAP = 5.seconds FETCH_OVERLAP = 5.seconds
# Used to filter Notes # Used to filter Notes
......
module AwardEmojiHelper module AwardEmojiHelper
prepend EE::AwardEmojiHelper
def toggle_award_url(awardable) def toggle_award_url(awardable)
return url_for([:toggle_award_emoji, awardable]) unless @project || awardable.is_a?(Note) return url_for([:toggle_award_emoji, awardable]) unless @project || awardable.is_a?(Note)
......
module NotesHelper module NotesHelper
prepend EE::NotesHelper
def note_target_fields(note) def note_target_fields(note)
if note.noteable if note.noteable
hidden_field_tag(:target_type, note.noteable.class.name.underscore) + hidden_field_tag(:target_type, note.noteable.class.name.underscore) +
...@@ -151,16 +153,17 @@ module NotesHelper ...@@ -151,16 +153,17 @@ module NotesHelper
} }
end end
def notes_data(issuable) def discussions_path(issuable)
discussions_path =
if issuable.is_a?(Issue) if issuable.is_a?(Issue)
discussions_project_issue_path(@project, issuable, format: :json) discussions_project_issue_path(@project, issuable, format: :json)
else else
discussions_project_merge_request_path(@project, issuable, format: :json) discussions_project_merge_request_path(@project, issuable, format: :json)
end end
end
def notes_data(issuable)
{ {
discussionsPath: discussions_path, discussionsPath: discussions_path(issuable),
registerPath: new_session_path(:user, redirect_to_referer: 'yes', anchor: 'register-pane'), registerPath: new_session_path(:user, redirect_to_referer: 'yes', anchor: 'register-pane'),
newSessionPath: new_session_path(:user, redirect_to_referer: 'yes'), newSessionPath: new_session_path(:user, redirect_to_referer: 'yes'),
markdownDocsPath: help_page_path('user/markdown'), markdownDocsPath: help_page_path('user/markdown'),
...@@ -170,7 +173,6 @@ module NotesHelper ...@@ -170,7 +173,6 @@ module NotesHelper
notesPath: notes_url, notesPath: notes_url,
totalNotes: issuable.discussions.length, totalNotes: issuable.discussions.length,
lastFetchedAt: Time.now.to_i lastFetchedAt: Time.now.to_i
}.to_json }.to_json
end end
......
...@@ -387,12 +387,15 @@ class Note < ActiveRecord::Base ...@@ -387,12 +387,15 @@ class Note < ActiveRecord::Base
def expire_etag_cache def expire_etag_cache
return unless noteable&.discussions_rendered_on_frontend? return unless noteable&.discussions_rendered_on_frontend?
key = Gitlab::Routing.url_helpers.project_noteable_notes_path( Gitlab::EtagCaching::Store.new.touch(etag_key)
end
def etag_key
Gitlab::Routing.url_helpers.project_noteable_notes_path(
project, project,
target_type: noteable_type.underscore, target_type: noteable_type.underscore,
target_id: noteable_id target_id: noteable_id
) )
Gitlab::EtagCaching::Store.new.touch(key)
end end
def touch(*args) def touch(*args)
......
...@@ -4,7 +4,9 @@ class DiscussionEntity < Grape::Entity ...@@ -4,7 +4,9 @@ class DiscussionEntity < Grape::Entity
expose :id, :reply_id expose :id, :reply_id
expose :expanded?, as: :expanded expose :expanded?, as: :expanded
expose :notes, using: NoteEntity expose :notes do |discussion, opts|
request.note_entity.represent(discussion.notes, opts)
end
expose :individual_note?, as: :individual_note expose :individual_note?, as: :individual_note
expose :resolvable?, as: :resolvable expose :resolvable?, as: :resolvable
...@@ -12,7 +14,7 @@ class DiscussionEntity < Grape::Entity ...@@ -12,7 +14,7 @@ class DiscussionEntity < Grape::Entity
expose :resolve_path, if: -> (d, _) { d.resolvable? } do |discussion| expose :resolve_path, if: -> (d, _) { d.resolvable? } do |discussion|
resolve_project_merge_request_discussion_path(discussion.project, discussion.noteable, discussion.id) resolve_project_merge_request_discussion_path(discussion.project, discussion.noteable, discussion.id)
end end
expose :resolve_with_issue_path do |discussion| expose :resolve_with_issue_path, if: -> (d, _) { d.resolvable? } do |discussion|
new_project_issue_path(discussion.project, merge_request_to_resolve_discussions_of: discussion.noteable.iid, discussion_to_resolve: discussion.id) new_project_issue_path(discussion.project, merge_request_to_resolve_discussions_of: discussion.noteable.iid, discussion_to_resolve: discussion.id)
end end
......
...@@ -5,10 +5,6 @@ class NoteEntity < API::Entities::Note ...@@ -5,10 +5,6 @@ class NoteEntity < API::Entities::Note
expose :author, using: NoteUserEntity expose :author, using: NoteUserEntity
expose :human_access do |note|
note.project.team.human_max_access(note.author_id)
end
unexpose :note, as: :body unexpose :note, as: :body
expose :note expose :note
...@@ -37,36 +33,10 @@ class NoteEntity < API::Entities::Note ...@@ -37,36 +33,10 @@ class NoteEntity < API::Entities::Note
expose :emoji_awardable?, as: :emoji_awardable expose :emoji_awardable?, as: :emoji_awardable
expose :award_emoji, if: -> (note, _) { note.emoji_awardable? }, using: AwardEmojiEntity expose :award_emoji, if: -> (note, _) { note.emoji_awardable? }, using: AwardEmojiEntity
expose :toggle_award_path, if: -> (note, _) { note.emoji_awardable? } do |note|
if note.for_personal_snippet?
toggle_award_emoji_snippet_note_path(note.noteable, note)
else
toggle_award_emoji_project_note_path(note.project, note.id)
end
end
expose :report_abuse_path do |note| expose :report_abuse_path do |note|
new_abuse_report_path(user_id: note.author.id, ref_url: Gitlab::UrlBuilder.build(note)) new_abuse_report_path(user_id: note.author.id, ref_url: Gitlab::UrlBuilder.build(note))
end end
expose :path do |note|
if note.for_personal_snippet?
snippet_note_path(note.noteable, note)
else
project_note_path(note.project, note)
end
end
expose :resolve_path, if: -> (note, _) { note.part_of_discussion? && note.resolvable? } do |note|
resolve_project_merge_request_discussion_path(note.project, note.noteable, note.discussion_id)
end
expose :resolve_with_issue_path, if: -> (note, _) { note.part_of_discussion? && note.resolvable? } do |note|
new_project_issue_path(note.project, merge_request_to_resolve_discussions_of: note.noteable.iid, discussion_to_resolve: note.discussion_id)
end
expose :attachment, using: NoteAttachmentEntity, if: -> (note, _) { note.attachment? } expose :attachment, using: NoteAttachmentEntity, if: -> (note, _) { note.attachment? }
expose :delete_attachment_path, if: -> (note, _) { note.attachment? } do |note|
delete_attachment_project_note_path(note.project, note)
end
end end
class NoteSerializer < BaseSerializer
entity NoteEntity
end
class ProjectNoteEntity < NoteEntity
expose :human_access do |note|
note.project.team.human_max_access(note.author_id)
end
expose :toggle_award_path, if: -> (note, _) { note.emoji_awardable? } do |note|
toggle_award_emoji_project_note_path(note.project, note.id)
end
expose :path do |note|
project_note_path(note.project, note)
end
expose :resolve_path, if: -> (note, _) { note.part_of_discussion? && note.resolvable? } do |note|
resolve_project_merge_request_discussion_path(note.project, note.noteable, note.discussion_id)
end
expose :resolve_with_issue_path, if: -> (note, _) { note.part_of_discussion? && note.resolvable? } do |note|
new_project_issue_path(note.project, merge_request_to_resolve_discussions_of: note.noteable.iid, discussion_to_resolve: note.discussion_id)
end
expose :delete_attachment_path, if: -> (note, _) { note.attachment? } do |note|
delete_attachment_project_note_path(note.project, note)
end
end
class ProjectNoteSerializer < BaseSerializer
entity ProjectNoteEntity
end
...@@ -82,12 +82,17 @@ constraints(::Constraints::GroupUrlConstrainer.new) do ...@@ -82,12 +82,17 @@ constraints(::Constraints::GroupUrlConstrainer.new) do
end end
resources :billings, only: [:index] resources :billings, only: [:index]
resources :epics do resources :epics, concerns: :awardable, constraints: { id: /\d+/ } do
member do member do
get :discussions, format: :json
get :realtime_changes get :realtime_changes
end end
resources :epic_issues, only: [:index, :create, :destroy, :update], as: 'issues', path: 'issues' resources :epic_issues, only: [:index, :create, :destroy, :update], as: 'issues', path: 'issues'
scope module: :epics do
resources :notes, only: [:index, :create, :destroy, :update], concerns: :awardable, constraints: { id: /\d+/ }
end
end end
# On CE only index and show are needed # On CE only index and show are needed
......
...@@ -97,3 +97,20 @@ You may also consult the [group permissions table][permissions]. ...@@ -97,3 +97,20 @@ You may also consult the [group permissions table][permissions].
[ee]: https://about.gitlab.com/products/ [ee]: https://about.gitlab.com/products/
[permissions]: ../../permissions.md#group-members-permissions [permissions]: ../../permissions.md#group-members-permissions
## Thread
- Comments: collaborate on that epic by posting comments in its thread.
These text fields also fully support
[GitLab Flavored Markdown](../../markdown.md#gitlab-flavored-markdown-gfm).
## Comment, or start a discussion
Once you wrote your comment, you can either:
- Click "Comment" and your comment will be published.
- Click "Start discussion": start a thread within that epic's thread to discuss specific points.
## Award emoji
- You can [award an emoji](../../award_emojis.md) to that epic or its comments.
import ZenMode from '~/zen_mode'; import ZenMode from '~/zen_mode';
import initEpicShow from 'ee/epics/epic_show/epic_show_bundle'; import initEpicShow from 'ee/epics/epic_show/epic_show_bundle';
import '~/notes/index';
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
new ZenMode(); // eslint-disable-line no-new new ZenMode(); // eslint-disable-line no-new
......
class Groups::Epics::NotesController < Groups::ApplicationController
include NotesActions
include NotesHelper
include ToggleAwardEmoji
before_action :epic
before_action :authorize_create_note!, only: [:create]
private
def project
nil
end
def note
@note ||= noteable.notes.find(params[:id])
end
alias_method :awardable, :note
def epic
@epic ||= @group.epics.find_by(iid: params[:epic_id])
return render_404 unless can?(current_user, :read_epic, @epic)
@epic
end
alias_method :noteable, :epic
def finder_params
params.merge(last_fetched_at: last_fetched_at, target_id: epic.id, target_type: 'epic', group_id: @group.id)
end
def authorize_create_note!
access_denied! unless can?(current_user, :create_note, noteable)
end
def note_serializer
EpicNoteSerializer.new(project: nil, noteable: noteable, current_user: current_user)
end
end
class Groups::EpicsController < Groups::ApplicationController class Groups::EpicsController < Groups::ApplicationController
include IssuableActions include IssuableActions
include IssuableCollections include IssuableCollections
include ToggleAwardEmoji
include RendersNotes
before_action :check_epics_available! before_action :check_epics_available!
before_action :epic, except: [:index, :create] before_action :epic, except: [:index, :create]
...@@ -23,7 +25,7 @@ class Groups::EpicsController < Groups::ApplicationController ...@@ -23,7 +25,7 @@ class Groups::EpicsController < Groups::ApplicationController
end end
def create def create
@epic = Epics::CreateService.new(@group, current_user, epic_params).execute @epic = ::Epics::CreateService.new(@group, current_user, epic_params).execute
if @epic.persisted? if @epic.persisted?
render json: { render json: {
...@@ -48,6 +50,7 @@ class Groups::EpicsController < Groups::ApplicationController ...@@ -48,6 +50,7 @@ class Groups::EpicsController < Groups::ApplicationController
@epic @epic
end end
alias_method :issuable, :epic alias_method :issuable, :epic
alias_method :awardable, :epic
def epic_params def epic_params
params.require(:epic).permit(*epic_params_attributes) params.require(:epic).permit(*epic_params_attributes)
...@@ -67,8 +70,12 @@ class Groups::EpicsController < Groups::ApplicationController ...@@ -67,8 +70,12 @@ class Groups::EpicsController < Groups::ApplicationController
EpicSerializer.new(current_user: current_user) EpicSerializer.new(current_user: current_user)
end end
def discussion_serializer
DiscussionSerializer.new(project: nil, noteable: issuable, current_user: current_user, note_entity: EpicNoteEntity)
end
def update_service def update_service
Epics::UpdateService.new(@group, current_user, epic_params) ::Epics::UpdateService.new(@group, current_user, epic_params)
end end
def finder_type def finder_type
......
module EE
module NotesFinder
extend ActiveSupport::Concern
extend ::Gitlab::Utils::Override
override :noteables_for_type
def noteables_for_type(noteable_type)
if noteable_type == "epic"
return EpicsFinder.new(@current_user, group_id: @params[:group_id]) # rubocop:disable Gitlab/ModuleWithInstanceVariables
end
super
end
end
end
module EE
module AwardEmojiHelper
extend ::Gitlab::Utils::Override
override :toggle_award_url
def toggle_award_url(awardable)
if awardable.is_a?(Note) && awardable.for_epic?
return toggle_award_emoji_group_epic_note_path(awardable.noteable.group, awardable.noteable, awardable)
elsif awardable.is_a?(Epic)
return toggle_award_emoji_group_epic_path(awardable.group, awardable)
end
super
end
end
end
module EE
module NotesHelper
extend ::Gitlab::Utils::Override
override :notes_url
def notes_url(params = {})
return group_epic_notes_path(@epic.group, @epic) if @epic.is_a?(Epic)
super
end
override :discussions_path
def discussions_path(issuable)
return discussions_group_epic_path(issuable.group, issuable, format: :json) if issuable.is_a?(Epic)
super
end
end
end
...@@ -7,6 +7,7 @@ module EE ...@@ -7,6 +7,7 @@ module EE
include Issuable include Issuable
include Noteable include Noteable
include Referable include Referable
include Awardable
belongs_to :assignee, class_name: "User" belongs_to :assignee, class_name: "User"
belongs_to :group belongs_to :group
...@@ -115,5 +116,9 @@ module EE ...@@ -115,5 +116,9 @@ module EE
def mentionable_params def mentionable_params
{ group: group } { group: group }
end end
def discussions_rendered_on_frontend?
true
end
end end
end end
...@@ -7,7 +7,6 @@ module EE ...@@ -7,7 +7,6 @@ module EE
include ObjectStorage::BackgroundMove include ObjectStorage::BackgroundMove
end end
override :for_project_noteable?
def for_epic? def for_epic?
noteable.is_a?(Epic) noteable.is_a?(Epic)
end end
...@@ -21,5 +20,21 @@ module EE ...@@ -21,5 +20,21 @@ module EE
def can_create_todo? def can_create_todo?
!for_epic? && super !for_epic? && super
end end
override :etag_key
def etag_key
if for_epic?
return ::Gitlab::Routing.url_helpers.group_epic_notes_path(noteable.group, noteable)
end
super
end
override :banzai_render_context
def banzai_render_context(field)
return super unless for_epic?
super.merge(group: noteable.group)
end
end end
end end
...@@ -12,4 +12,18 @@ class EpicEntity < IssuableEntity ...@@ -12,4 +12,18 @@ class EpicEntity < IssuableEntity
group_epic_path(epic.group, epic) group_epic_path(epic.group, epic)
end end
expose :labels, using: LabelEntity expose :labels, using: LabelEntity
expose :current_user do
expose :can_create_note do |epic|
can?(request.current_user, :create_note, epic)
end
end
expose :create_note_path do |epic|
group_epic_notes_path(epic.group, epic)
end
expose :preview_note_path do |epic|
preview_markdown_path(epic.group, quick_actions_target_type: 'Epic', quick_actions_target_id: epic.id)
end
end end
class EpicNoteEntity < NoteEntity
expose :toggle_award_path, if: -> (note, _) { note.emoji_awardable? } do |note|
toggle_award_emoji_group_epic_note_path(note.noteable.group, note.noteable, note)
end
expose :path do |note|
group_epic_note_path(note.noteable.group, note.noteable, note)
end
end
class EpicNoteSerializer < BaseSerializer
entity EpicNoteEntity
end
- @gfm_form = true
%section.js-vue-notes-event
#js-vue-notes{ data: { notes_data: notes_data(@epic),
noteable_data: EpicSerializer.new(current_user: current_user).represent(@epic).to_json,
current_user_data: UserSerializer.new.represent(current_user, only_path: true).to_json, noteable_type: 'epic' } }
...@@ -13,3 +13,11 @@ ...@@ -13,3 +13,11 @@
- page_card_attributes @epic.card_attributes - page_card_attributes @epic.card_attributes
#epic-show-app{ data: epic_show_app_data(@epic, author_icon: avatar_icon_for_user(@epic.author), initial: issuable_initial_data(@epic)) } #epic-show-app{ data: epic_show_app_data(@epic, author_icon: avatar_icon_for_user(@epic.author), initial: issuable_initial_data(@epic)) }
.content-block.emoji-block
.row
.col-sm-8.js-noteable-awards
= render 'award_emoji/awards_block', awardable: @epic, inline: true
%section.issuable-discussion
= render 'discussion'
---
title: Add comment thread to Epics
merge_request:
author:
type: added
module EE
module Gitlab
module EtagCaching
module Router
module ClassMethods
def match(path)
epic_route = ::Gitlab::EtagCaching::Router::Route.new(
%r(^/groups/#{::Gitlab::PathRegex.full_namespace_route_regex}/-/epics/\d+/notes\z),
'epic_notes'
)
return epic_route if epic_route.regexp.match(path)
super
end
end
def self.prepended(base)
base.singleton_class.prepend ClassMethods
end
end
end
end
end
require 'spec_helper'
describe Groups::Epics::NotesController do
let(:user) { create(:user) }
let(:group) { create(:group) }
let(:epic) { create(:epic, group: group) }
let(:note) { create(:note, noteable: epic) }
let(:parsed_response) { JSON.parse(response.body).with_indifferent_access }
before do
stub_licensed_features(epics: true)
end
describe 'GET index' do
let(:request_params) do
{
group_id: group,
epic_id: epic.iid,
format: 'json'
}
end
let(:note_json) { parsed_response[:notes].first }
before do
group.add_developer(user)
sign_in(user)
note
end
it 'responds with array of notes' do
get :index, request_params
expect(parsed_response[:notes]).to be_an Array
expect(parsed_response[:notes].count).to eq(1)
end
context 'with cross-reference system note that is not visible to the current user', :request_store do
it "does not return any note" do
expect_any_instance_of(Note).to receive(:cross_reference_not_visible_for?).and_return(true)
get :index, request_params
expect(parsed_response[:notes].count).to eq(0)
end
end
end
describe 'POST create' do
let(:request_params) do
{
note: { note: 'some note', noteable_id: epic.id, noteable_type: 'Epic' },
group_id: group,
epic_id: epic.iid,
format: 'json'
}
end
before do
sign_in(user)
group.add_developer(user)
end
it "returns status 302 for html" do
post :create, request_params.merge(format: :html)
expect(response).to have_gitlab_http_status(302)
end
it "returns status 200 for json" do
post :create, request_params
expect(response).to have_gitlab_http_status(200)
expect(parsed_response[:id]).not_to be_nil
end
end
describe 'PUT update' do
let(:request_params) do
{
note: { note: 'updated note', noteable_id: epic.id, noteable_type: 'Epic' },
group_id: group,
epic_id: epic.iid,
id: note.id,
format: 'json'
}
end
before do
sign_in(note.author)
end
it "updates the note" do
expect { put :update, request_params }.to change { note.reload.note }
end
end
describe 'DELETE destroy' do
let(:request_params) do
{
group_id: group,
epic_id: epic.iid,
id: note.id,
format: 'js'
}
end
before do
group.add_developer(user)
end
context 'user is the author of a note' do
before do
sign_in(note.author)
end
it "returns status 200" do
delete :destroy, request_params
expect(response).to have_gitlab_http_status(200)
end
it "deletes the note" do
expect { delete :destroy, request_params }.to change { Note.count }.from(1).to(0)
end
end
context 'user is not the author of the note' do
before do
sign_in(user)
end
it "returns status 404" do
delete :destroy, request_params
expect(response).to have_gitlab_http_status(404)
end
end
end
describe 'POST toggle_award_emoji' do
let(:request_params) do
{
group_id: group,
epic_id: epic,
id: note.id
}
end
before do
group.add_developer(user)
sign_in(user)
end
it "toggles the award emoji" do
expect do
post(:toggle_award_emoji, request_params.merge(name: "thumbsup"))
end.to change { note.award_emoji.count }.by(1)
expect(response).to have_gitlab_http_status(200)
end
it "removes the already awarded emoji" do
post(:toggle_award_emoji, request_params.merge(name: "thumbsup"))
expect do
post(:toggle_award_emoji, request_params.merge(name: "thumbsup"))
end.to change { AwardEmoji.count }.by(-1)
expect(response).to have_gitlab_http_status(200)
end
end
end
require 'spec_helper'
describe 'Discussion Comments Epic', :js do
let(:user) { create(:user) }
let(:epic) { create(:epic) }
before do
stub_licensed_features(epics: true)
epic.group.add_master(user)
sign_in(user)
visit group_epic_path(epic.group, epic)
end
it_behaves_like 'discussion comments', 'epic'
end
...@@ -46,7 +46,7 @@ feature 'Update Epic', :js do ...@@ -46,7 +46,7 @@ feature 'Update Epic', :js do
fill_in 'issuable-title', with: 'New epic title' fill_in 'issuable-title', with: 'New epic title'
fill_in 'issue-description', with: 'New epic description' fill_in 'issue-description', with: 'New epic description'
click_link('Preview') page.within('.detail-page-description') { click_link('Preview') }
expect(find('.md-preview')).to have_content('New epic description') expect(find('.md-preview')).to have_content('New epic description')
click_button 'Save changes' click_button 'Save changes'
...@@ -56,7 +56,7 @@ feature 'Update Epic', :js do ...@@ -56,7 +56,7 @@ feature 'Update Epic', :js do
end end
it 'edits full screen' do it 'edits full screen' do
find('.js-zen-enter').click page.within('.detail-page-description') { find('.js-zen-enter').click }
expect(page).to have_selector('.div-dropzone-wrapper.fullscreen') expect(page).to have_selector('.div-dropzone-wrapper.fullscreen')
end end
...@@ -68,7 +68,7 @@ feature 'Update Epic', :js do ...@@ -68,7 +68,7 @@ feature 'Update Epic', :js do
expect(page.find_field("issue-description").value).to have_content('banana_sample') expect(page.find_field("issue-description").value).to have_content('banana_sample')
click_link('Preview') page.within('.detail-page-description') { click_link('Preview') }
wait_for_requests wait_for_requests
within('.md-preview') do within('.md-preview') do
......
...@@ -49,7 +49,12 @@ ...@@ -49,7 +49,12 @@
"avatar_url": { "type": "uri" } "avatar_url": { "type": "uri" }
} }
} }
} },
"current_user": {
"can_create_note": { "type": "boolean" }
},
"create_note_path": { "type": "string" },
"preview_note_path": { "type": "string" }
}, },
"additionalProperties": false "additionalProperties": false
} }
require 'spec_helper'
describe Gitlab::EtagCaching::Router do
it 'matches epic notes endpoint' do
result = described_class.match(
'/groups/my-group/and-subgroup/-/epics/1/notes'
)
expect(result).to be_present
expect(result.name).to eq 'epic_notes'
end
it 'does not match invalid epic notes endpoint' do
result = described_class.match(
'/groups/my-group/-/and-subgroup/-/epics/1/notes'
)
expect(result).to be_blank
end
end
require 'spec_helper'
describe EpicNoteEntity do
include Gitlab::Routing
let(:request) { double('request', current_user: user, noteable: note.noteable) }
let(:entity) { described_class.new(note, request: request) }
let(:epic) { create(:epic, author: user) }
let(:note) { create(:note, noteable: epic, author: user) }
let(:user) { create(:user) }
subject { entity.as_json }
it_behaves_like 'note entity'
it 'exposes epic-specific elements' do
expect(subject).to include(:toggle_award_path, :path)
end
end
module Gitlab module Gitlab
module EtagCaching module EtagCaching
class Router class Router
prepend EE::Gitlab::EtagCaching::Router
Route = Struct.new(:regexp, :name) Route = Struct.new(:regexp, :name)
# We enable an ETag for every request matching the regex. # We enable an ETag for every request matching the regex.
# To match a regex the path needs to match the following: # To match a regex the path needs to match the following:
......
...@@ -974,7 +974,7 @@ describe Projects::IssuesController do ...@@ -974,7 +974,7 @@ describe Projects::IssuesController do
it 'returns discussion json' do it 'returns discussion json' do
get :discussions, namespace_id: project.namespace, project_id: project, id: issue.iid get :discussions, namespace_id: project.namespace, project_id: project, id: issue.iid
expect(json_response.first.keys).to match_array(%w[id reply_id expanded notes diff_discussion individual_note resolvable resolve_with_issue_path resolved]) expect(json_response.first.keys).to match_array(%w[id reply_id expanded notes diff_discussion individual_note resolvable resolved])
end end
it 'filters notes that the user should not see' do it 'filters notes that the user should not see' do
......
...@@ -6,7 +6,7 @@ describe DiscussionEntity do ...@@ -6,7 +6,7 @@ describe DiscussionEntity do
let(:user) { create(:user) } let(:user) { create(:user) }
let(:note) { create(:discussion_note_on_merge_request) } let(:note) { create(:discussion_note_on_merge_request) }
let(:discussion) { note.discussion } let(:discussion) { note.discussion }
let(:request) { double('request') } let(:request) { double('request', note_entity: ProjectNoteEntity) }
let(:controller) { double('controller') } let(:controller) { double('controller') }
let(:entity) { described_class.new(discussion, request: request, context: controller) } let(:entity) { described_class.new(discussion, request: request, context: controller) }
......
...@@ -10,53 +10,5 @@ describe NoteEntity do ...@@ -10,53 +10,5 @@ describe NoteEntity do
let(:user) { create(:user) } let(:user) { create(:user) }
subject { entity.as_json } subject { entity.as_json }
context 'basic note' do it_behaves_like 'note entity'
it 'exposes correct elements' do
expect(subject).to include(:type, :author, :human_access, :note, :note_html, :current_user,
:discussion_id, :emoji_awardable, :award_emoji, :toggle_award_path, :report_abuse_path, :path, :attachment)
end
it 'does not expose elements for specific notes cases' do
expect(subject).not_to include(:last_edited_by, :last_edited_at, :system_note_icon_name)
end
it 'exposes author correctly' do
expect(subject[:author]).to include(:id, :name, :username, :state, :avatar_url, :path)
end
it 'does not expose web_url for author' do
expect(subject[:author]).not_to include(:web_url)
end
end
context 'when note was edited' do
before do
note.update(updated_at: 1.minute.from_now, updated_by: user)
end
it 'exposes last_edited_at and last_edited_by elements' do
expect(subject).to include(:last_edited_at, :last_edited_by)
end
end
context 'when note is a system note' do
before do
note.update(system: true)
end
it 'exposes system_note_icon_name element' do
expect(subject).to include(:system_note_icon_name)
end
end
context 'when note is part of resolvable discussion' do
before do
allow(note).to receive(:part_of_discussion?).and_return(true)
allow(note).to receive(:resolvable?).and_return(true)
end
it 'exposes paths to resolve note' do
expect(subject).to include(:resolve_path, :resolve_with_issue_path)
end
end
end end
require 'spec_helper'
describe ProjectNoteEntity do
include Gitlab::Routing
let(:request) { double('request', current_user: user, noteable: note.noteable) }
let(:entity) { described_class.new(note, request: request) }
let(:note) { create(:note) }
let(:user) { create(:user) }
subject { entity.as_json }
it_behaves_like 'note entity'
it 'exposes project-specific elements' do
expect(subject).to include(:human_access, :toggle_award_path, :path)
end
context 'when note is part of resolvable discussion' do
before do
allow(note).to receive(:part_of_discussion?).and_return(true)
allow(note).to receive(:resolvable?).and_return(true)
end
it 'exposes paths to resolve note' do
expect(subject).to include(:resolve_path, :resolve_with_issue_path)
end
end
end
...@@ -81,7 +81,10 @@ shared_examples 'discussion comments' do |resource_name| ...@@ -81,7 +81,10 @@ shared_examples 'discussion comments' do |resource_name|
# on issues page, the menu closes when clicking anywhere, on other pages it will # on issues page, the menu closes when clicking anywhere, on other pages it will
# remain open if clicking divider or menu padding, but should not change button action # remain open if clicking divider or menu padding, but should not change button action
if resource_name == 'issue' #
# if dropdown menu is not toggled (and also not present),
# it's "issue-type" dropdown
if first(menu_selector).nil?
expect(find(dropdown_selector)).to have_content 'Comment' expect(find(dropdown_selector)).to have_content 'Comment'
find(toggle_selector).click find(toggle_selector).click
...@@ -107,8 +110,10 @@ shared_examples 'discussion comments' do |resource_name| ...@@ -107,8 +110,10 @@ shared_examples 'discussion comments' do |resource_name|
end end
it 'updates the submit button text and closes the dropdown' do it 'updates the submit button text and closes the dropdown' do
button = find(submit_selector)
# on issues page, the submit input is a <button>, on other pages it is <input> # on issues page, the submit input is a <button>, on other pages it is <input>
if resource_name == 'issue' if button.tag_name == 'button'
expect(find(submit_selector)).to have_content 'Start discussion' expect(find(submit_selector)).to have_content 'Start discussion'
else else
expect(find(submit_selector).value).to eq 'Start discussion' expect(find(submit_selector).value).to eq 'Start discussion'
...@@ -132,6 +137,8 @@ shared_examples 'discussion comments' do |resource_name| ...@@ -132,6 +137,8 @@ shared_examples 'discussion comments' do |resource_name|
describe 'creating a discussion' do describe 'creating a discussion' do
before do before do
find(submit_selector).click find(submit_selector).click
wait_for_requests
find(comments_selector, match: :first) find(comments_selector, match: :first)
end end
...@@ -197,11 +204,13 @@ shared_examples 'discussion comments' do |resource_name| ...@@ -197,11 +204,13 @@ shared_examples 'discussion comments' do |resource_name|
end end
it 'updates the submit button text and closes the dropdown' do it 'updates the submit button text and closes the dropdown' do
button = find(submit_selector)
# on issues page, the submit input is a <button>, on other pages it is <input> # on issues page, the submit input is a <button>, on other pages it is <input>
if resource_name == 'issue' if button.tag_name == 'button'
expect(find(submit_selector)).to have_content 'Comment' expect(button).to have_content 'Comment'
else else
expect(find(submit_selector).value).to eq 'Comment' expect(button.value).to eq 'Comment'
end end
expect(page).not_to have_selector menu_selector expect(page).not_to have_selector menu_selector
......
shared_examples 'note entity' do
subject { entity.as_json }
context 'basic note' do
it 'exposes correct elements' do
expect(subject).to include(:type, :author, :note, :note_html, :current_user,
:discussion_id, :emoji_awardable, :award_emoji, :report_abuse_path, :attachment)
end
it 'does not expose elements for specific notes cases' do
expect(subject).not_to include(:last_edited_by, :last_edited_at, :system_note_icon_name)
end
it 'exposes author correctly' do
expect(subject[:author]).to include(:id, :name, :username, :state, :avatar_url, :path)
end
it 'does not expose web_url for author' do
expect(subject[:author]).not_to include(:web_url)
end
end
context 'when note was edited' do
before do
note.update(updated_at: 1.minute.from_now, updated_by: user)
end
it 'exposes last_edited_at and last_edited_by elements' do
expect(subject).to include(:last_edited_at, :last_edited_by)
end
end
context 'when note is a system note' do
before do
note.update(system: true)
end
it 'exposes system_note_icon_name element' do
expect(subject).to include(:system_note_icon_name)
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