Commit 3930c3fc authored by Douwe Maan's avatar Douwe Maan

Merge branch '5480-epic-notifications' into 'master'

Resolve "Email notifications for epics"

Closes #5480

See merge request gitlab-org/gitlab-ee!5496
parents ca162184 74c7f240
...@@ -51,6 +51,7 @@ ...@@ -51,6 +51,7 @@
* We need to emit this event on both component & eventHub * We need to emit this event on both component & eventHub
* for 2 dependencies; * for 2 dependencies;
* *
* Make Epic sidebar auto-expand when participants & label icon is clicked
* 1. eventHub: This component is used in Issue Boards sidebar * 1. eventHub: This component is used in Issue Boards sidebar
* where component template is part of HAML * where component template is part of HAML
* and event listeners are tied to app's eventHub. * and event listeners are tied to app's eventHub.
......
class SentNotificationsController < ApplicationController class SentNotificationsController < ApplicationController
prepend EE::SentNotificationsController
skip_before_action :authenticate_user! skip_before_action :authenticate_user!
def unsubscribe def unsubscribe
...@@ -17,16 +19,20 @@ class SentNotificationsController < ApplicationController ...@@ -17,16 +19,20 @@ class SentNotificationsController < ApplicationController
flash[:notice] = "You have been unsubscribed from this thread." flash[:notice] = "You have been unsubscribed from this thread."
if current_user if current_user
case noteable redirect_to noteable_path(noteable)
when Issue
redirect_to issue_path(noteable)
when MergeRequest
redirect_to merge_request_path(noteable)
else
redirect_to root_path
end
else else
redirect_to new_user_session_path redirect_to new_user_session_path
end end
end end
def noteable_path(noteable)
case noteable
when Issue
issue_path(noteable)
when MergeRequest
merge_request_path(noteable)
else
root_path
end
end
end end
module EmailsHelper module EmailsHelper
prepend EE::EmailsHelper
include AppearancesHelper include AppearancesHelper
# Google Actions # Google Actions
......
module Emails module Emails
module Notes module Notes
prepend Emails::EE::Notes
def note_commit_email(recipient_id, note_id) def note_commit_email(recipient_id, note_id)
setup_note_mail(note_id, recipient_id) setup_note_mail(note_id, recipient_id)
...@@ -43,7 +45,7 @@ module Emails ...@@ -43,7 +45,7 @@ module Emails
private private
def note_target_url_options def note_target_url_options
[@project, @note.noteable, anchor: "note_#{@note.id}"] [@project || @group, @note.noteable, anchor: "note_#{@note.id}"]
end end
def note_thread_options(recipient_id) def note_thread_options(recipient_id)
...@@ -58,8 +60,9 @@ module Emails ...@@ -58,8 +60,9 @@ module Emails
# `note_id` is a `Note` when originating in `NotifyPreview` # `note_id` is a `Note` when originating in `NotifyPreview`
@note = note_id.is_a?(Note) ? note_id : Note.find(note_id) @note = note_id.is_a?(Note) ? note_id : Note.find(note_id)
@project = @note.project @project = @note.project
@group = @note.noteable.try(:group)
if @project && @note.persisted? if (@project || @group) && @note.persisted?
@sent_notification = SentNotification.record_note(@note, recipient_id, reply_key) @sent_notification = SentNotification.record_note(@note, recipient_id, reply_key)
end end
end end
......
class Notify < BaseMailer class Notify < BaseMailer
prepend EE::Notify
include ActionDispatch::Routing::PolymorphicRoutes include ActionDispatch::Routing::PolymorphicRoutes
include GitlabRoutingHelper include GitlabRoutingHelper
...@@ -98,6 +100,7 @@ class Notify < BaseMailer ...@@ -98,6 +100,7 @@ class Notify < BaseMailer
def subject(*extra) def subject(*extra)
subject = "" subject = ""
subject << "#{@project.name} | " if @project subject << "#{@project.name} | " if @project
subject << "#{@group.name} | " if @group
subject << extra.join(' | ') if extra.present? subject << extra.join(' | ') if extra.present?
subject << " | #{Gitlab.config.gitlab.email_subject_suffix}" if Gitlab.config.gitlab.email_subject_suffix.present? subject << " | #{Gitlab.config.gitlab.email_subject_suffix}" if Gitlab.config.gitlab.email_subject_suffix.present?
subject subject
...@@ -121,10 +124,9 @@ class Notify < BaseMailer ...@@ -121,10 +124,9 @@ class Notify < BaseMailer
@reason = headers['X-GitLab-NotificationReason'] @reason = headers['X-GitLab-NotificationReason']
if Gitlab::IncomingEmail.enabled? && @sent_notification if Gitlab::IncomingEmail.enabled? && @sent_notification
address = Mail::Address.new(Gitlab::IncomingEmail.reply_address(reply_key)) headers['Reply-To'] = Mail::Address.new(Gitlab::IncomingEmail.reply_address(reply_key)).tap do |address|
address.display_name = @project.full_name address.display_name = reply_display_name(model)
end
headers['Reply-To'] = address
fallback_reply_message_id = "<reply-#{reply_key}@#{Gitlab.config.gitlab.host}>".freeze fallback_reply_message_id = "<reply-#{reply_key}@#{Gitlab.config.gitlab.host}>".freeze
headers['References'] ||= [] headers['References'] ||= []
...@@ -136,6 +138,11 @@ class Notify < BaseMailer ...@@ -136,6 +138,11 @@ class Notify < BaseMailer
mail(headers) mail(headers)
end end
# `model` is used on EE code
def reply_display_name(_model)
@project.full_name
end
# Send an email that starts a new conversation thread, # Send an email that starts a new conversation thread,
# with headers suitable for grouping by thread in email clients. # with headers suitable for grouping by thread in email clients.
# #
......
...@@ -325,10 +325,6 @@ class Note < ActiveRecord::Base ...@@ -325,10 +325,6 @@ class Note < ActiveRecord::Base
!system? && !for_snippet? !system? && !for_snippet?
end end
def can_create_notification?
true
end
def discussion_class(noteable = nil) def discussion_class(noteable = nil)
# When commit notes are rendered on an MR's Discussion page, they are # When commit notes are rendered on an MR's Discussion page, they are
# displayed in one discussion instead of individually. # displayed in one discussion instead of individually.
......
...@@ -5,14 +5,14 @@ class SentNotification < ActiveRecord::Base ...@@ -5,14 +5,14 @@ class SentNotification < ActiveRecord::Base
belongs_to :noteable, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations belongs_to :noteable, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
belongs_to :recipient, class_name: "User" belongs_to :recipient, class_name: "User"
validates :project, :recipient, presence: true validates :recipient, presence: true
validates :reply_key, presence: true, uniqueness: true validates :reply_key, presence: true, uniqueness: true
validates :noteable_id, presence: true, unless: :for_commit? validates :noteable_id, presence: true, unless: :for_commit?
validates :commit_id, presence: true, if: :for_commit? validates :commit_id, presence: true, if: :for_commit?
validates :in_reply_to_discussion_id, format: { with: /\A\h{40}\z/, allow_nil: true } validates :in_reply_to_discussion_id, format: { with: /\A\h{40}\z/, allow_nil: true }
validate :note_valid validate :note_valid
after_save :keep_around_commit after_save :keep_around_commit, if: :for_commit?
class << self class << self
def reply_key def reply_key
......
...@@ -45,6 +45,10 @@ module NotificationRecipientService ...@@ -45,6 +45,10 @@ module NotificationRecipientService
target.project target.project
end end
def group
project&.group || target.try(:group)
end
def recipients def recipients
@recipients ||= [] @recipients ||= []
end end
...@@ -67,6 +71,7 @@ module NotificationRecipientService ...@@ -67,6 +71,7 @@ module NotificationRecipientService
user, type, user, type,
reason: reason, reason: reason,
project: project, project: project,
group: group,
custom_action: custom_action, custom_action: custom_action,
target: target, target: target,
acting_user: acting_user acting_user: acting_user
...@@ -107,11 +112,11 @@ module NotificationRecipientService ...@@ -107,11 +112,11 @@ module NotificationRecipientService
# Users with a notification setting on group or project # Users with a notification setting on group or project
user_ids += user_ids_notifiable_on(project, :custom) user_ids += user_ids_notifiable_on(project, :custom)
user_ids += user_ids_notifiable_on(project.group, :custom) user_ids += user_ids_notifiable_on(group, :custom)
# Users with global level custom # Users with global level custom
user_ids_with_project_level_global = user_ids_notifiable_on(project, :global) user_ids_with_project_level_global = user_ids_notifiable_on(project, :global)
user_ids_with_group_level_global = user_ids_notifiable_on(project.group, :global) user_ids_with_group_level_global = user_ids_notifiable_on(group, :global)
global_users_ids = user_ids_with_project_level_global.concat(user_ids_with_group_level_global) global_users_ids = user_ids_with_project_level_global.concat(user_ids_with_group_level_global)
user_ids += user_ids_with_global_level_custom(global_users_ids, custom_action) user_ids += user_ids_with_global_level_custom(global_users_ids, custom_action)
...@@ -123,6 +128,10 @@ module NotificationRecipientService ...@@ -123,6 +128,10 @@ module NotificationRecipientService
add_recipients(project_watchers, :watch, nil) add_recipients(project_watchers, :watch, nil)
end end
def add_group_watchers
add_recipients(group_watchers, :watch, nil)
end
# Get project users with WATCH notification level # Get project users with WATCH notification level
def project_watchers def project_watchers
project_members_ids = user_ids_notifiable_on(project) project_members_ids = user_ids_notifiable_on(project)
...@@ -138,6 +147,14 @@ module NotificationRecipientService ...@@ -138,6 +147,14 @@ module NotificationRecipientService
user_scope.where(id: user_ids_with_project_setting.concat(user_ids_with_group_setting).uniq) user_scope.where(id: user_ids_with_project_setting.concat(user_ids_with_group_setting).uniq)
end end
def group_watchers
user_ids_with_group_global = user_ids_notifiable_on(group, :global)
user_ids = user_ids_with_global_level_watch(user_ids_with_group_global)
user_ids_with_group_setting = select_group_members_ids(group, [], user_ids_with_group_global, user_ids)
user_scope.where(id: user_ids_with_group_setting)
end
def add_subscribed_users def add_subscribed_users
return unless target.respond_to? :subscribers return unless target.respond_to? :subscribers
...@@ -281,6 +298,14 @@ module NotificationRecipientService ...@@ -281,6 +298,14 @@ module NotificationRecipientService
note.project note.project
end end
def group
if note.for_project_noteable?
project.group
else
target.try(:group)
end
end
def build! def build!
# Add all users participating in the thread (author, assignee, comment authors) # Add all users participating in the thread (author, assignee, comment authors)
add_participants(note.author) add_participants(note.author)
...@@ -289,11 +314,11 @@ module NotificationRecipientService ...@@ -289,11 +314,11 @@ module NotificationRecipientService
if note.for_project_noteable? if note.for_project_noteable?
# Merge project watchers # Merge project watchers
add_project_watchers add_project_watchers
else
# Merge project with custom notification add_group_watchers
add_custom_notifications
end end
add_custom_notifications
add_subscribed_users add_subscribed_users
end end
......
...@@ -5,7 +5,7 @@ class NewNoteWorker ...@@ -5,7 +5,7 @@ class NewNoteWorker
# old `NewNoteWorker` jobs (can remove later) # old `NewNoteWorker` jobs (can remove later)
def perform(note_id, _params = {}) def perform(note_id, _params = {})
if note = Note.find_by(id: note_id) if note = Note.find_by(id: note_id)
NotificationService.new.new_note(note) if note.can_create_notification? NotificationService.new.new_note(note)
Notes::PostProcessService.new(note).execute Notes::PostProcessService.new(note).execute
else else
Rails.logger.error("NewNoteWorker: couldn't find note with ID=#{note_id}, skipping job") Rails.logger.error("NewNoteWorker: couldn't find note with ID=#{note_id}, skipping job")
......
...@@ -95,6 +95,7 @@ constraints(::Constraints::GroupUrlConstrainer.new) do ...@@ -95,6 +95,7 @@ constraints(::Constraints::GroupUrlConstrainer.new) do
member do member do
get :discussions, format: :json get :discussions, format: :json
get :realtime_changes get :realtime_changes
post :toggle_subscription
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'
......
...@@ -90,6 +90,14 @@ ...@@ -90,6 +90,14 @@
type: Array, type: Array,
required: true, required: true,
}, },
participants: {
type: Array,
required: true,
},
subscribed: {
type: Boolean,
required: true,
},
namespace: { namespace: {
type: String, type: String,
required: false, required: false,
...@@ -99,6 +107,10 @@ ...@@ -99,6 +107,10 @@
type: String, type: String,
required: true, required: true,
}, },
toggleSubscriptionPath: {
type: String,
required: true,
},
labelsWebUrl: { labelsWebUrl: {
type: String, type: String,
required: true, required: true,
...@@ -163,9 +175,12 @@ ...@@ -163,9 +175,12 @@
:initial-start-date="startDate" :initial-start-date="startDate"
:initial-end-date="endDate" :initial-end-date="endDate"
:initial-labels="labels" :initial-labels="labels"
:initial-participants="participants"
:initial-subscribed="subscribed"
:namespace="namespace" :namespace="namespace"
:update-path="updateEndpoint" :update-path="updateEndpoint"
:labels-path="labelsPath" :labels-path="labelsPath"
:toggle-subscription-path="toggleSubscriptionPath"
:labels-web-url="labelsWebUrl" :labels-web-url="labelsWebUrl"
:epics-web-url="epicsWebUrl" :epics-web-url="epicsWebUrl"
/> />
......
...@@ -3,12 +3,15 @@ ...@@ -3,12 +3,15 @@
import _ from 'underscore'; import _ from 'underscore';
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
import Flash from '~/flash'; import Flash from '~/flash';
import { __ } from '~/locale';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import ListLabel from '~/vue_shared/models/label'; import ListLabel from '~/vue_shared/models/label';
import SidebarDatePicker from '~/vue_shared/components/sidebar/date_picker.vue'; import SidebarDatePicker from '~/vue_shared/components/sidebar/date_picker.vue';
import SidebarCollapsedGroupedDatePicker from '~/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue'; import SidebarCollapsedGroupedDatePicker from '~/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue';
import ToggleSidebar from '~/vue_shared/components/sidebar/toggle_sidebar.vue'; import ToggleSidebar from '~/vue_shared/components/sidebar/toggle_sidebar.vue';
import SidebarLabelsSelect from '~/vue_shared/components/sidebar/labels_select/base.vue'; import SidebarLabelsSelect from '~/vue_shared/components/sidebar/labels_select/base.vue';
import SidebarParticipants from './sidebar_participants.vue';
import SidebarSubscriptions from './sidebar_subscriptions.vue';
import SidebarService from '../services/sidebar_service'; import SidebarService from '../services/sidebar_service';
import Store from '../stores/sidebar_store'; import Store from '../stores/sidebar_store';
...@@ -19,6 +22,8 @@ ...@@ -19,6 +22,8 @@
SidebarDatePicker, SidebarDatePicker,
SidebarCollapsedGroupedDatePicker, SidebarCollapsedGroupedDatePicker,
SidebarLabelsSelect, SidebarLabelsSelect,
SidebarParticipants,
SidebarSubscriptions,
}, },
props: { props: {
endpoint: { endpoint: {
...@@ -42,6 +47,14 @@ ...@@ -42,6 +47,14 @@
type: Array, type: Array,
required: true, required: true,
}, },
initialParticipants: {
type: Array,
required: true,
},
initialSubscribed: {
type: Boolean,
required: true,
},
namespace: { namespace: {
type: String, type: String,
required: false, required: false,
...@@ -55,6 +68,10 @@ ...@@ -55,6 +68,10 @@
type: String, type: String,
required: true, required: true,
}, },
toggleSubscriptionPath: {
type: String,
required: true,
},
labelsWebUrl: { labelsWebUrl: {
type: String, type: String,
required: true, required: true,
...@@ -68,6 +85,7 @@ ...@@ -68,6 +85,7 @@
const store = new Store({ const store = new Store({
startDate: this.initialStartDate, startDate: this.initialStartDate,
endDate: this.initialEndDate, endDate: this.initialEndDate,
subscribed: this.initialSubscribed,
}); });
return { return {
...@@ -77,7 +95,8 @@ ...@@ -77,7 +95,8 @@
autoExpanded: false, autoExpanded: false,
savingStartDate: false, savingStartDate: false,
savingEndDate: false, savingEndDate: false,
service: new SidebarService(this.endpoint), savingSubscription: false,
service: new SidebarService(this.endpoint, this.toggleSubscriptionPath),
epicContext: { epicContext: {
labels: this.initialLabels, labels: this.initialLabels,
}, },
...@@ -158,6 +177,19 @@ ...@@ -158,6 +177,19 @@
this.toggleSidebar(); this.toggleSidebar();
} }
}, },
handleToggleSubscribed() {
this.service.toggleSubscribed()
.then(() => {
this.store.setSubscribed(!this.store.subscribed);
})
.catch(() => {
if (this.store.subscribed) {
Flash(__('An error occurred while unsubscribing to notifications.'));
} else {
Flash(__('An error occurred while subscribing to notifications.'));
}
});
},
}, },
}; };
</script> </script>
...@@ -222,6 +254,16 @@ ...@@ -222,6 +254,16 @@
> >
{{ __('None') }} {{ __('None') }}
</sidebar-labels-select> </sidebar-labels-select>
<sidebar-participants
:participants="initialParticipants"
@toggleCollapse="toggleSidebar"
/>
<sidebar-subscriptions
:loading="savingSubscription"
:subscribed="store.subscribed"
@toggleSubscription="handleToggleSubscribed"
@toggleCollapse="toggleSidebar"
/>
</div> </div>
</aside> </aside>
</template> </template>
<script>
import Participants from '~/sidebar/components/participants/participants.vue';
export default {
components: {
Participants,
},
props: {
participants: {
type: Array,
required: true,
},
},
methods: {
onToggleSidebar() {
this.$emit('toggleCollapse');
},
},
};
</script>
<template>
<div class="block participants">
<participants
:participants="participants"
@toggleSidebar="onToggleSidebar"
/>
</div>
</template>
<script>
import Subscriptions from '~/sidebar/components/subscriptions/subscriptions.vue';
export default {
components: {
Subscriptions,
},
props: {
loading: {
type: Boolean,
required: true,
},
subscribed: {
type: Boolean,
required: true,
},
},
methods: {
onToggleSubscription() {
this.$emit('toggleSubscription');
},
onToggleSidebar() {
this.$emit('toggleCollapse');
},
},
};
</script>
<template>
<div class="block subscriptions">
<subscriptions
:loading="loading"
:subscribed="subscribed"
@toggleSubscription="onToggleSubscription"
@toggleSidebar="onToggleSidebar"
/>
</div>
</template>
import Vue from 'vue'; import axios from '~/lib/utils/axios_utils';
import VueResource from 'vue-resource';
Vue.use(VueResource);
export default class SidebarService { export default class SidebarService {
constructor(endpoint) { constructor(endpoint, subscriptionEndpoint) {
this.endpoint = endpoint; this.endpoint = endpoint;
this.resource = Vue.resource(`${this.endpoint}.json`, {}); this.subscriptionEndpoint = subscriptionEndpoint;
} }
updateStartDate(startDate) { updateStartDate(startDate) {
return this.resource.update({ return axios.put(this.endpoint, { start_date: startDate });
start_date: startDate,
});
} }
updateEndDate(endDate) { updateEndDate(endDate) {
return this.resource.update({ return axios.put(this.endpoint, { end_date: endDate });
end_date: endDate, }
});
toggleSubscribed() {
return axios.post(this.subscriptionEndpoint);
} }
} }
import { parsePikadayDate } from '~/lib/utils/datefix'; import { parsePikadayDate } from '~/lib/utils/datefix';
export default class SidebarStore { export default class SidebarStore {
constructor({ startDate, endDate }) { constructor({ startDate, endDate, subscribed }) {
this.startDate = startDate; this.startDate = startDate;
this.endDate = endDate; this.endDate = endDate;
this.subscribed = subscribed;
} }
get startDateTime() { get startDateTime() {
...@@ -13,4 +14,8 @@ export default class SidebarStore { ...@@ -13,4 +14,8 @@ export default class SidebarStore {
get endDateTime() { get endDateTime() {
return this.endDate ? parsePikadayDate(this.endDate) : null; return this.endDate ? parsePikadayDate(this.endDate) : null;
} }
setSubscribed(subscribed) {
this.subscribed = subscribed;
}
} }
module EE
module SentNotificationsController
extend ::Gitlab::Utils::Override
private
override :noteable_path
def noteable_path(noteable)
return epic_path(noteable) if noteable.is_a?(Epic)
super
end
end
end
...@@ -2,6 +2,7 @@ class Groups::EpicsController < Groups::ApplicationController ...@@ -2,6 +2,7 @@ class Groups::EpicsController < Groups::ApplicationController
include IssuableActions include IssuableActions
include IssuableCollections include IssuableCollections
include ToggleAwardEmoji include ToggleAwardEmoji
include ToggleSubscriptionAction
include RendersNotes include RendersNotes
before_action :check_epics_available! before_action :check_epics_available!
...@@ -51,6 +52,11 @@ class Groups::EpicsController < Groups::ApplicationController ...@@ -51,6 +52,11 @@ class Groups::EpicsController < Groups::ApplicationController
end end
alias_method :issuable, :epic alias_method :issuable, :epic
alias_method :awardable, :epic alias_method :awardable, :epic
alias_method :subscribable_resource, :epic
def subscribable_project
nil
end
def epic_params def epic_params
params.require(:epic).permit(*epic_params_attributes) params.require(:epic).permit(*epic_params_attributes)
......
module EE
module EmailsHelper
extend ::Gitlab::Utils::Override
override :action_title
def action_title(url)
return "View Epic" if url.split("/").include?('epics')
super
end
end
end
...@@ -15,11 +15,17 @@ module EpicsHelper ...@@ -15,11 +15,17 @@ module EpicsHelper
end_date: epic.end_date end_date: epic.end_date
} }
participants = UserSerializer.new.represent(epic.participants)
initial = opts[:initial].merge(labels: epic.labels,
participants: participants,
subscribed: epic.subscribed?(current_user))
{ {
initial: opts[:initial].merge(labels: epic.labels).to_json, initial: initial.to_json,
meta: epic_meta.to_json, meta: epic_meta.to_json,
namespace: group.path, namespace: group.path,
labels_path: group_labels_path(group, format: :json, only_group_labels: true, include_ancestor_groups: true), labels_path: group_labels_path(group, format: :json, only_group_labels: true, include_ancestor_groups: true),
toggle_subscription_path: toggle_subscription_group_epic_path(group, epic),
labels_web_url: group_labels_path(group), labels_web_url: group_labels_path(group),
epics_web_url: group_epics_path(group) epics_web_url: group_epics_path(group)
} }
......
module EE
module Notify
extend ActiveSupport::Concern
extend ::Gitlab::Utils::Override
included do
attr_reader :group
end
private
override :reply_display_name
def reply_display_name(model)
return super unless model.is_a?(Epic)
group.full_name
end
end
end
module Emails
module EE
module Notes
extend ::Gitlab::Utils::Override
def note_epic_email(recipient_id, note_id)
setup_note_mail(note_id, recipient_id)
@epic = @note.noteable
@target_url = group_epic_url(*note_target_url_options)
mail_answer_note_thread(@epic, @note, note_thread_options(recipient_id))
end
end
end
end
...@@ -21,11 +21,6 @@ module EE ...@@ -21,11 +21,6 @@ module EE
!for_epic? && super !for_epic? && super
end end
override :can_create_notification?
def can_create_notification?
!for_epic? && super
end
override :etag_key override :etag_key
def etag_key def etag_key
if for_epic? if for_epic?
......
...@@ -2,8 +2,7 @@ ...@@ -2,8 +2,7 @@
- @no_container = false - @no_container = false
- @content_class = "limit-container-width" unless fluid_layout - @content_class = "limit-container-width" unless fluid_layout
-# TODO: Move this to @epic.to_reference when implementing gitlab-ee#3853 - epic_reference = @epic.to_reference
- epic_reference = "&#{@epic.iid}"
- add_to_breadcrumbs _("Epics"), group_epics_path(@group) - add_to_breadcrumbs _("Epics"), group_epics_path(@group)
- breadcrumb_title epic_reference - breadcrumb_title epic_reference
......
---
title: Email notifications for epics
merge_request:
author:
type: added
require 'rails_helper'
describe SentNotificationsController do
let(:user) { create(:user) }
context 'Unsubscribing from an epic' do
let(:epic) do
create(:epic, author: user) do |epic|
epic.subscriptions.create(user: user, project: nil, subscribed: true)
end
end
let(:sent_notification) { create(:sent_notification, project: nil, noteable: epic, recipient: user) }
before do
sign_in(user)
get(:unsubscribe, id: sent_notification.reply_key)
end
it 'unsubscribes the user' do
expect(epic.subscribed?(user)).to be_falsey
end
it 'sets the flash message' do
expect(controller).to set_flash[:notice].to(/unsubscribed/)
end
it 'redirects to the merge request page' do
expect(response)
.to redirect_to(group_epic_path(epic.group, epic))
end
end
end
require "spec_helper"
describe EE::EmailsHelper do
describe '#action_title' do
using RSpec::Parameterized::TableSyntax
where(:path, :result) do
'somedomain.com/groups/agroup/-/epics/231' | 'View Epic'
'somedomain.com/aproject/issues/231' | 'View Issue'
'somedomain.com/aproject/merge_requests/231' | 'View Merge request'
'somedomain.com/aproject/commit/al3f231' | 'View Commit'
end
with_them do
it 'should return the expected title' do
title = helper.action_title(path)
expect(title).to eq(result)
end
end
end
end
...@@ -8,10 +8,12 @@ describe EpicsHelper do ...@@ -8,10 +8,12 @@ describe EpicsHelper do
user = create(:user) user = create(:user)
@epic = create(:epic, author: user) @epic = create(:epic, author: user)
data = epic_show_app_data(@epic, initial: {}, author_icon: 'icon_path') allow(helper).to receive(:current_user).and_return(user)
data = helper.epic_show_app_data(@epic, initial: {}, author_icon: 'icon_path')
meta_data = JSON.parse(data[:meta]) meta_data = JSON.parse(data[:meta])
expected_keys = %i(initial meta namespace labels_path labels_web_url epics_web_url) expected_keys = %i(initial meta namespace labels_path toggle_subscription_path labels_web_url epics_web_url)
expect(data.keys).to match_array(expected_keys) expect(data.keys).to match_array(expected_keys)
expect(meta_data.keys).to match_array(%w[created author start_date end_date]) expect(meta_data.keys).to match_array(%w[created author start_date end_date])
expect(meta_data['author']).to eq({ expect(meta_data['author']).to eq({
......
require 'spec_helper'
describe Gitlab::Email::Handler::CreateNoteHandler do
include_context :email_shared_context
before do
stub_incoming_email_setting(enabled: true, address: "reply+%{key}@appmail.adventuretime.ooo")
stub_config_setting(host: 'localhost')
stub_licensed_features(epics: true)
end
let(:email_raw) { fixture_file('emails/valid_reply.eml') }
let(:group) { create(:group_with_members) }
let(:user) { group.users.first }
let(:noteable) { create(:epic, group: group) }
let(:note) { create(:note, project: nil, noteable: noteable)}
let!(:sent_notification) do
SentNotification.record_note(note, user.id, mail_key)
end
context "when the note could not be saved" do
before do
allow_any_instance_of(Note).to receive(:persisted?).and_return(false)
end
it "raises an InvalidNoteError" do
expect { receiver.execute }.to raise_error(Gitlab::Email::InvalidNoteError)
end
end
context 'when the note contains quick actions' do
let!(:email_raw) { fixture_file("emails/commands_in_reply.eml") }
context 'and current user cannot update the noteable' do
it 'only executes the commands that the user can perform' do
expect { receiver.execute }
.to change { noteable.notes.user.count }.by(1)
end
end
context 'and current user can update noteable' do
before do
group.add_developer(user)
end
it 'posts a note and updates the noteable' do
expect(TodoService.new.todo_exist?(noteable, user)).to be_falsy
expect { receiver.execute }
.to change { noteable.notes.user.count }.by(1)
end
end
end
context "when the reply is blank" do
let!(:email_raw) { fixture_file("emails/no_content_reply.eml") }
it "raises an EmptyEmailError" do
expect { receiver.execute }.to raise_error(Gitlab::Email::EmptyEmailError)
end
end
context "when everything is fine" do
before do
setup_attachment
end
it "creates a comment" do
expect { receiver.execute }.to change { noteable.notes.count }.by(1)
new_note = noteable.notes.last
expect(new_note.author).to eq(sent_notification.recipient)
expect(new_note.position).to eq(note.position)
expect(new_note.note).to include("I could not disagree more.")
expect(new_note.in_reply_to?(note)).to be_truthy
end
it "adds all attachments" do
receiver.execute
note = noteable.notes.last
expect(note.note).to include(markdown)
end
context 'when sub-addressing is not supported' do
before do
stub_incoming_email_setting(enabled: true, address: nil)
end
shared_examples 'an email that contains a mail key' do |header|
it "fetches the mail key from the #{header} header and creates a comment" do
expect { receiver.execute }.to change { noteable.notes.count }.by(1)
new_note = noteable.notes.last
expect(new_note.author).to eq(sent_notification.recipient)
expect(new_note.position).to eq(note.position)
expect(new_note.note).to include('I could not disagree more.')
end
end
context 'mail key is in the References header' do
let(:email_raw) { fixture_file('emails/reply_without_subaddressing_and_key_inside_references.eml') }
it_behaves_like 'an email that contains a mail key', 'References'
end
context 'mail key is in the References header with a comma' do
let(:email_raw) { fixture_file('emails/reply_without_subaddressing_and_key_inside_references_with_a_comma.eml') }
it_behaves_like 'an email that contains a mail key', 'References'
end
end
end
end
require 'spec_helper' require 'spec_helper'
require Rails.root.join('spec/lib/gitlab/email/email_shared_blocks')
describe Gitlab::Email::Handler::EE::ServiceDeskHandler do describe Gitlab::Email::Handler::EE::ServiceDeskHandler do
include_context :email_shared_context include_context :email_shared_context
......
...@@ -149,4 +149,50 @@ describe Notify do ...@@ -149,4 +149,50 @@ describe Notify do
end end
end end
end end
context 'for a group' do
context 'for epic notes' do
set(:group) { create(:group) }
set(:epic) { create(:epic, group: group) }
set(:note) { create(:note, project: nil, noteable: epic) }
let(:note_author) { note.author }
let(:epic_note_path) { group_epic_path(group, epic, anchor: "note_#{note.id}") }
subject { described_class.note_epic_email(recipient.id, note.id) }
it_behaves_like 'a note email'
it_behaves_like 'an unsubscribeable thread'
it 'has the characteristics of a threaded reply' do
host = Gitlab.config.gitlab.host
route_key = "#{epic.class.model_name.singular_route_key}_#{epic.id}"
aggregate_failures do
is_expected.to have_header('Message-ID', /\A<.*@#{host}>\Z/)
is_expected.to have_header('In-Reply-To', "<#{route_key}@#{host}>")
is_expected.to have_header('References', /\A<#{route_key}@#{host}> <reply\-.*@#{host}>\Z/ )
is_expected.to have_subject(/^Re: /)
end
end
context 'when reply-by-email is enabled with incoming address with %{key}' do
it 'has a Reply-To header' do
is_expected.to have_header 'Reply-To', /<reply+(.*)@#{Gitlab.config.gitlab.host}>\Z/
end
end
it { is_expected.to have_body_text('View Epic') }
it 'has the correct subject and body' do
prefix = "Re: #{epic.group.name} | "
suffix = "#{epic.title} (#{epic.to_reference})"
aggregate_failures do
is_expected.to have_subject [prefix, suffix].compact.join
is_expected.to have_body_text(epic_note_path)
end
end
end
end
end end
require 'spec_helper' require 'spec_helper'
describe EE::NotificationService, :mailer do describe EE::NotificationService, :mailer do
include NotificationHelpers
include ExternalAuthorizationServiceHelpers include ExternalAuthorizationServiceHelpers
let(:subject) { NotificationService.new } let(:subject) { NotificationService.new }
...@@ -236,4 +237,122 @@ describe EE::NotificationService, :mailer do ...@@ -236,4 +237,122 @@ describe EE::NotificationService, :mailer do
subject.project_mirror_user_changed(new_mirror_user, mirror_user.name, project) subject.project_mirror_user_changed(new_mirror_user, mirror_user.name, project)
end end
end end
describe 'Notes' do
around do |example|
perform_enqueued_jobs do
example.run
end
end
context 'epic notes' do
set(:group) { create(:group, :private) }
set(:epic) { create(:epic, group: group) }
set(:note) { create(:note, project: nil, noteable: epic, note: '@mention referenced, @unsubscribed_mentioned and @outsider also') }
before(:all) do
create(:group_member, group: group, user: epic.author)
create(:group_member, group: group, user: note.author)
end
before do
stub_licensed_features(epics: true)
build_group_members(group)
@u_custom_off = create_user_with_notification(:custom, 'custom_off', group)
create(:group_member, group: group, user: @u_custom_off)
create(
:note,
project: nil,
noteable: epic,
author: @u_custom_off,
note: 'i think @subscribed_participant should see this'
)
update_custom_notification(:new_note, @u_guest_custom, resource: group)
update_custom_notification(:new_note, @u_custom_global)
end
describe '#new_note' do
it do
add_users_with_subscription(group, epic)
reset_delivered_emails!
expect(SentNotification).to receive(:record).with(epic, any_args).exactly(9).times
subject.new_note(note)
should_email(@u_watcher)
should_email(note.noteable.author)
should_email(@u_custom_global)
should_email(@u_mentioned)
should_email(@subscriber)
should_email(@watcher_and_subscriber)
should_email(@subscribed_participant)
should_email(@u_custom_off)
should_email(@unsubscribed_mentioned)
should_not_email(@u_guest_custom)
should_not_email(@u_guest_watcher)
should_not_email(note.author)
should_not_email(@u_participating)
should_not_email(@u_disabled)
should_not_email(@unsubscriber)
should_not_email(@u_outsider_mentioned)
should_not_email(@u_lazy_participant)
end
end
end
end
def build_group_members(group)
@u_watcher = create_global_setting_for(create(:user), :watch)
@u_participating = create_global_setting_for(create(:user), :participating)
@u_participant_mentioned = create_global_setting_for(create(:user, username: 'participant'), :participating)
@u_disabled = create_global_setting_for(create(:user), :disabled)
@u_mentioned = create_global_setting_for(create(:user, username: 'mention'), :mention)
@u_committer = create(:user, username: 'committer')
@u_not_mentioned = create_global_setting_for(create(:user, username: 'regular'), :participating)
@u_outsider_mentioned = create(:user, username: 'outsider')
@u_custom_global = create_global_setting_for(create(:user, username: 'custom_global'), :custom)
# User to be participant by default
# This user does not contain any record in notification settings table
# It should be treated with a :participating notification_level
@u_lazy_participant = create(:user, username: 'lazy-participant')
@u_guest_watcher = create_user_with_notification(:watch, 'guest_watching', group)
@u_guest_custom = create_user_with_notification(:custom, 'guest_custom', group)
create(:group_member, group: group, user: @u_watcher)
create(:group_member, group: group, user: @u_participating)
create(:group_member, group: group, user: @u_participant_mentioned)
create(:group_member, group: group, user: @u_disabled)
create(:group_member, group: group, user: @u_mentioned)
create(:group_member, group: group, user: @u_committer)
create(:group_member, group: group, user: @u_not_mentioned)
create(:group_member, group: group, user: @u_lazy_participant)
create(:group_member, group: group, user: @u_custom_global)
end
def add_users_with_subscription(group, issuable)
@subscriber = create :user
@unsubscriber = create :user
@unsubscribed_mentioned = create :user, username: 'unsubscribed_mentioned'
@subscribed_participant = create_global_setting_for(create(:user, username: 'subscribed_participant'), :participating)
@watcher_and_subscriber = create_global_setting_for(create(:user), :watch)
create(:group_member, group: group, user: @subscribed_participant)
create(:group_member, group: group, user: @subscriber)
create(:group_member, group: group, user: @unsubscriber)
create(:group_member, group: group, user: @watcher_and_subscriber)
create(:group_member, group: group, user: @unsubscribed_mentioned)
issuable.subscriptions.create(user: @unsubscribed_mentioned, subscribed: false)
issuable.subscriptions.create(user: @subscriber, subscribed: true)
issuable.subscriptions.create(user: @subscribed_participant, subscribed: true)
issuable.subscriptions.create(user: @unsubscriber, subscribed: false)
# Make the watcher a subscriber to detect dupes
issuable.subscriptions.create(user: @watcher_and_subscriber, subscribed: true)
end
end end
...@@ -8,6 +8,7 @@ module Gitlab ...@@ -8,6 +8,7 @@ module Gitlab
include ReplyProcessing include ReplyProcessing
delegate :project, to: :sent_notification, allow_nil: true delegate :project, to: :sent_notification, allow_nil: true
delegate :noteable, to: :sent_notification
def can_handle? def can_handle?
mail_key =~ /\A\w+\z/ mail_key =~ /\A\w+\z/
...@@ -18,7 +19,7 @@ module Gitlab ...@@ -18,7 +19,7 @@ module Gitlab
validate_permission!(:create_note) validate_permission!(:create_note)
raise NoteableNotFoundError unless sent_notification.noteable raise NoteableNotFoundError unless noteable
raise EmptyEmailError if message.blank? raise EmptyEmailError if message.blank?
verify_record!( verify_record!(
......
...@@ -32,8 +32,12 @@ module Gitlab ...@@ -32,8 +32,12 @@ module Gitlab
def validate_permission!(permission) def validate_permission!(permission)
raise UserNotFoundError unless author raise UserNotFoundError unless author
raise UserBlockedError if author.blocked? raise UserBlockedError if author.blocked?
raise ProjectNotFound unless author.can?(:read_project, project)
raise UserNotAuthorizedError unless author.can?(permission, project) if project
raise ProjectNotFound unless author.can?(:read_project, project)
end
raise UserNotAuthorizedError unless author.can?(permission, project || noteable)
end end
def verify_record!(record:, invalid_exception:, record_name:) def verify_record!(record:, invalid_exception:, record_name:)
......
...@@ -40,6 +40,9 @@ describe('EpicShowApp', () => { ...@@ -40,6 +40,9 @@ describe('EpicShowApp', () => {
labelsWebUrl, labelsWebUrl,
epicsWebUrl, epicsWebUrl,
labels, labels,
participants,
subscribed,
toggleSubscriptionPath,
} = props; } = props;
const EpicShowApp = Vue.extend(epicShowApp); const EpicShowApp = Vue.extend(epicShowApp);
...@@ -68,6 +71,7 @@ describe('EpicShowApp', () => { ...@@ -68,6 +71,7 @@ describe('EpicShowApp', () => {
projectPath: props.groupPath, projectPath: props.groupPath,
projectNamespace: '', projectNamespace: '',
showInlineEditButton: true, showInlineEditButton: true,
toggleSubscriptionPath,
}); });
const EpicSidebar = Vue.extend(epicSidebar); const EpicSidebar = Vue.extend(epicSidebar);
...@@ -77,7 +81,10 @@ describe('EpicShowApp', () => { ...@@ -77,7 +81,10 @@ describe('EpicShowApp', () => {
initialStartDate: startDate, initialStartDate: startDate,
initialEndDate: endDate, initialEndDate: endDate,
initialLabels: labels, initialLabels: labels,
initialParticipants: participants,
initialSubscribed: subscribed,
updatePath: updateEndpoint, updatePath: updateEndpoint,
toggleSubscriptionPath,
labelsPath, labelsPath,
labelsWebUrl, labelsWebUrl,
epicsWebUrl, epicsWebUrl,
......
...@@ -8,8 +8,28 @@ export const mockLabels = [ ...@@ -8,8 +8,28 @@ export const mockLabels = [
}, },
]; ];
export const mockParticipants = [
{
id: 1,
name: 'Administrator',
username: 'root',
state: 'active',
avatar_url: '',
web_url: 'http://127.0.0.1:3001/root',
},
{
id: 12,
name: 'Susy Johnson',
username: 'tana_harvey',
state: 'active',
avatar_url: '',
web_url: 'http://127.0.0.1:3001/tana_harvey',
},
];
export const contentProps = { export const contentProps = {
endpoint: '', endpoint: '',
toggleSubscriptionPath: gl.TEST_HOST,
updateEndpoint: gl.TEST_HOST, updateEndpoint: gl.TEST_HOST,
canAdmin: true, canAdmin: true,
canUpdate: true, canUpdate: true,
...@@ -27,6 +47,8 @@ export const contentProps = { ...@@ -27,6 +47,8 @@ export const contentProps = {
startDate: '2017-01-01', startDate: '2017-01-01',
endDate: '2017-10-10', endDate: '2017-10-10',
labels: mockLabels, labels: mockLabels,
participants: mockParticipants,
subscribed: true,
}; };
export const headerProps = { export const headerProps = {
......
import Vue from 'vue'; import Vue from 'vue';
import _ from 'underscore'; import _ from 'underscore';
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import epicSidebar from 'ee/epics/sidebar/components/sidebar_app.vue'; import epicSidebar from 'ee/epics/sidebar/components/sidebar_app.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper'; import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { props } from '../../epic_show/mock_data'; import { props } from '../../epic_show/mock_data';
...@@ -15,8 +18,23 @@ describe('epicSidebar', () => { ...@@ -15,8 +18,23 @@ describe('epicSidebar', () => {
labelsWebUrl, labelsWebUrl,
epicsWebUrl, epicsWebUrl,
labels, labels,
participants,
subscribed,
toggleSubscriptionPath,
} = props; } = props;
const defaultPropsData = {
endpoint: gl.TEST_HOST,
initialLabels: labels,
initialParticipants: participants,
initialSubscribed: subscribed,
updatePath: updateEndpoint,
toggleSubscriptionPath,
labelsPath,
labelsWebUrl,
epicsWebUrl,
};
beforeEach(() => { beforeEach(() => {
setFixtures(` setFixtures(`
<div class="page-with-contextual-sidebar right-sidebar-expanded"> <div class="page-with-contextual-sidebar right-sidebar-expanded">
...@@ -27,14 +45,7 @@ describe('epicSidebar', () => { ...@@ -27,14 +45,7 @@ describe('epicSidebar', () => {
originalCookieState = Cookies.get('collapsed_gutter'); originalCookieState = Cookies.get('collapsed_gutter');
Cookies.set('collapsed_gutter', null); Cookies.set('collapsed_gutter', null);
EpicSidebar = Vue.extend(epicSidebar); EpicSidebar = Vue.extend(epicSidebar);
vm = mountComponent(EpicSidebar, { vm = mountComponent(EpicSidebar, defaultPropsData, '#epic-sidebar');
endpoint: gl.TEST_HOST,
initialLabels: labels,
updatePath: updateEndpoint,
labelsPath,
labelsWebUrl,
epicsWebUrl,
}, '#epic-sidebar');
}); });
afterEach(() => { afterEach(() => {
...@@ -46,44 +57,22 @@ describe('epicSidebar', () => { ...@@ -46,44 +57,22 @@ describe('epicSidebar', () => {
}); });
it('should render min date sidebar-date-picker', () => { it('should render min date sidebar-date-picker', () => {
vm = mountComponent(EpicSidebar, { vm = mountComponent(EpicSidebar, Object.assign({}, defaultPropsData, { initialStartDate: '2017-01-01' }));
endpoint: gl.TEST_HOST,
initialStartDate: '2017-01-01',
initialLabels: labels,
updatePath: updateEndpoint,
labelsPath,
labelsWebUrl,
epicsWebUrl,
});
expect(vm.$el.querySelector('.value-content strong').innerText.trim()).toEqual('Jan 1, 2017'); expect(vm.$el.querySelector('.value-content strong').innerText.trim()).toEqual('Jan 1, 2017');
}); });
it('should render max date sidebar-date-picker', () => { it('should render max date sidebar-date-picker', () => {
vm = mountComponent(EpicSidebar, { vm = mountComponent(EpicSidebar, Object.assign({}, defaultPropsData, { initialEndDate: '2018-01-01' }));
endpoint: gl.TEST_HOST,
initialEndDate: '2018-01-01',
initialLabels: labels,
updatePath: updateEndpoint,
labelsPath,
labelsWebUrl,
epicsWebUrl,
});
expect(vm.$el.querySelector('.value-content strong').innerText.trim()).toEqual('Jan 1, 2018'); expect(vm.$el.querySelector('.value-content strong').innerText.trim()).toEqual('Jan 1, 2018');
}); });
it('should render both sidebar-date-picker', () => { it('should render both sidebar-date-picker', () => {
vm = mountComponent(EpicSidebar, { vm = mountComponent(EpicSidebar, Object.assign({}, defaultPropsData, {
endpoint: gl.TEST_HOST,
initialStartDate: '2017-01-01', initialStartDate: '2017-01-01',
initialEndDate: '2018-01-01', initialEndDate: '2018-01-01',
initialLabels: labels, }));
updatePath: updateEndpoint,
labelsPath,
labelsWebUrl,
epicsWebUrl,
});
const startDatePicker = vm.$el.querySelector('.block.start-date'); const startDatePicker = vm.$el.querySelector('.block.start-date');
const endDatePicker = vm.$el.querySelector('.block.end-date'); const endDatePicker = vm.$el.querySelector('.block.end-date');
...@@ -94,15 +83,7 @@ describe('epicSidebar', () => { ...@@ -94,15 +83,7 @@ describe('epicSidebar', () => {
describe('when collapsed', () => { describe('when collapsed', () => {
beforeEach(() => { beforeEach(() => {
Cookies.set('collapsed_gutter', 'true'); Cookies.set('collapsed_gutter', 'true');
vm = mountComponent(EpicSidebar, { vm = mountComponent(EpicSidebar, Object.assign({}, defaultPropsData, { initialStartDate: '2017-01-01' }));
endpoint: gl.TEST_HOST,
initialStartDate: '2017-01-01',
initialLabels: labels,
updatePath: updateEndpoint,
labelsPath,
labelsWebUrl,
epicsWebUrl,
});
}); });
it('should render right-sidebar-collapsed class', () => { it('should render right-sidebar-collapsed class', () => {
...@@ -138,30 +119,20 @@ describe('epicSidebar', () => { ...@@ -138,30 +119,20 @@ describe('epicSidebar', () => {
}); });
describe('saveDate', () => { describe('saveDate', () => {
let interceptor;
let component; let component;
let mock;
beforeEach(() => { beforeEach(() => {
interceptor = (request, next) => { mock = new MockAdapter(axios);
next(request.respondWith(JSON.stringify({}), { mock.onPut(gl.TEST_HOST).reply(() => [200, JSON.stringify({})]);
status: 200,
}));
};
Vue.http.interceptors.push(interceptor);
component = new EpicSidebar({ component = new EpicSidebar({
propsData: { propsData: defaultPropsData,
endpoint: gl.TEST_HOST,
initialLabels: labels,
updatePath: updateEndpoint,
labelsPath,
labelsWebUrl,
epicsWebUrl,
},
}); });
}); });
afterEach(() => { afterEach(() => {
Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor); mock.restore();
}); });
it('should save startDate', (done) => { it('should save startDate', (done) => {
...@@ -253,14 +224,7 @@ describe('epicSidebar', () => { ...@@ -253,14 +224,7 @@ describe('epicSidebar', () => {
}; };
Vue.http.interceptors.push(interceptor); Vue.http.interceptors.push(interceptor);
component = new EpicSidebar({ component = new EpicSidebar({
propsData: { propsData: defaultPropsData,
endpoint: gl.TEST_HOST,
initialLabels: labels,
updatePath: updateEndpoint,
labelsPath,
labelsWebUrl,
epicsWebUrl,
},
}); });
}); });
......
import Vue from 'vue';
import SidebarParticipants from 'ee/epics/sidebar/components/sidebar_participants.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { mockParticipants } from '../../epic_show/mock_data';
const createComponent = () => {
const Component = Vue.extend(SidebarParticipants);
return mountComponent(Component, {
participants: mockParticipants,
});
};
describe('SidebarParticipants', () => {
let vm;
beforeEach(() => {
vm = createComponent();
});
afterEach(() => {
vm.$destroy();
});
describe('methods', () => {
describe('onToggleSidebar', () => {
it('emits `toggleCollapse` event on component', () => {
spyOn(vm, '$emit');
vm.onToggleSidebar();
expect(vm.$emit).toHaveBeenCalledWith('toggleCollapse');
});
});
});
describe('template', () => {
it('renders component container element with classes `block participants`', () => {
expect(vm.$el.classList.contains('block', 'participants')).toBe(true);
});
it('renders participants list element', () => {
expect(vm.$el.querySelector('.participants-list')).not.toBeNull();
expect(vm.$el.querySelectorAll('.js-participants-author').length).toBe(mockParticipants.length);
});
});
});
import Vue from 'vue';
import SidebarSubscriptions from 'ee/epics/sidebar/components/sidebar_subscriptions.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
const createComponent = () => {
const Component = Vue.extend(SidebarSubscriptions);
return mountComponent(Component, {
loading: false,
subscribed: true,
});
};
describe('SidebarSubscriptions', () => {
let vm;
beforeEach(() => {
vm = createComponent();
});
afterEach(() => {
vm.$destroy();
});
describe('methods', () => {
describe('onToggleSubscription', () => {
it('emits `toggleSubscription` event on component', () => {
spyOn(vm, '$emit');
vm.onToggleSubscription();
expect(vm.$emit).toHaveBeenCalledWith('toggleSubscription');
});
});
describe('onToggleSidebar', () => {
it('emits `toggleCollapse` event on component', () => {
spyOn(vm, '$emit');
vm.onToggleSidebar();
expect(vm.$emit).toHaveBeenCalledWith('toggleCollapse');
});
});
});
describe('template', () => {
it('renders component container element with classes `block subscriptions`', () => {
expect(vm.$el.classList.contains('block', 'subscriptions')).toBe(true);
});
it('renders subscription toggle element', () => {
expect(vm.$el.querySelector('.project-feature-toggle')).not.toBeNull();
});
});
});
...@@ -54,4 +54,13 @@ describe('Sidebar Store', () => { ...@@ -54,4 +54,13 @@ describe('Sidebar Store', () => {
expect(date.getFullYear()).toEqual(2017); expect(date.getFullYear()).toEqual(2017);
}); });
}); });
describe('setSubscribed', () => {
it('should set store.subscribed value', () => {
const store = new SidebarStore({ subscribed: true });
store.setSubscribed(false);
expect(store.subscribed).toEqual(false);
});
});
}); });
require 'spec_helper' require 'spec_helper'
require_relative '../email_shared_blocks'
describe Gitlab::Email::Handler::CreateIssueHandler do describe Gitlab::Email::Handler::CreateIssueHandler do
include_context :email_shared_context include_context :email_shared_context
......
require 'spec_helper' require 'spec_helper'
require_relative '../email_shared_blocks'
describe Gitlab::Email::Handler::CreateMergeRequestHandler do describe Gitlab::Email::Handler::CreateMergeRequestHandler do
include_context :email_shared_context include_context :email_shared_context
......
require 'spec_helper' require 'spec_helper'
require_relative '../email_shared_blocks'
describe Gitlab::Email::Handler::CreateNoteHandler do describe Gitlab::Email::Handler::CreateNoteHandler do
include_context :email_shared_context include_context :email_shared_context
......
require 'spec_helper' require 'spec_helper'
require_relative '../email_shared_blocks'
describe Gitlab::Email::Handler::UnsubscribeHandler do describe Gitlab::Email::Handler::UnsubscribeHandler do
include_context :email_shared_context include_context :email_shared_context
......
require 'spec_helper' require 'spec_helper'
require_relative 'email_shared_blocks'
describe Gitlab::Email::Receiver do describe Gitlab::Email::Receiver do
include_context :email_shared_context include_context :email_shared_context
......
...@@ -665,38 +665,6 @@ describe Notify do ...@@ -665,38 +665,6 @@ describe Notify do
allow(Note).to receive(:find).with(note.id).and_return(note) allow(Note).to receive(:find).with(note.id).and_return(note)
end end
shared_examples 'a note email' do
it_behaves_like 'it should have Gmail Actions links'
it 'is sent to the given recipient as the author' do
sender = subject.header[:from].addrs[0]
aggregate_failures do
expect(sender.display_name).to eq(note_author.name)
expect(sender.address).to eq(gitlab_sender)
expect(subject).to deliver_to(recipient.notification_email)
end
end
it 'contains the message from the note' do
is_expected.to have_html_escaped_body_text note.note
end
it 'does not contain note author' do
is_expected.not_to have_body_text note.author_name
end
context 'when enabled email_author_in_body' do
before do
stub_application_setting(email_author_in_body: true)
end
it 'contains a link to note author' do
is_expected.to have_html_escaped_body_text note.author_name
end
end
end
describe 'on a commit' do describe 'on a commit' do
let(:commit) { project.commit } let(:commit) { project.commit }
......
...@@ -2,6 +2,7 @@ require 'spec_helper' ...@@ -2,6 +2,7 @@ require 'spec_helper'
describe NotificationService, :mailer do describe NotificationService, :mailer do
include EmailSpec::Matchers include EmailSpec::Matchers
include NotificationHelpers
let(:notification) { described_class.new } let(:notification) { described_class.new }
let(:assignee) { create(:user) } let(:assignee) { create(:user) }
...@@ -13,12 +14,6 @@ describe NotificationService, :mailer do ...@@ -13,12 +14,6 @@ describe NotificationService, :mailer do
end end
shared_examples 'notifications for new mentions' do shared_examples 'notifications for new mentions' do
def send_notifications(*new_mentions)
mentionable.description = new_mentions.map(&:to_reference).join(' ')
notification.send(notification_method, mentionable, new_mentions, @u_disabled)
end
it 'sends no emails when no new mentions are present' do it 'sends no emails when no new mentions are present' do
send_notifications send_notifications
should_not_email_anyone should_not_email_anyone
...@@ -1956,30 +1951,6 @@ describe NotificationService, :mailer do ...@@ -1956,30 +1951,6 @@ describe NotificationService, :mailer do
group group
end end
def create_global_setting_for(user, level)
setting = user.global_notification_setting
setting.level = level
setting.save
user
end
def create_user_with_notification(level, username, resource = project)
user = create(:user, username: username)
setting = user.notification_settings_for(resource)
setting.level = level
setting.save
user
end
# Create custom notifications
# When resource is nil it means global notification
def update_custom_notification(event, user, resource: nil, value: true)
setting = user.notification_settings_for(resource)
setting.update!(event => value)
end
def add_users_with_subscription(project, issuable) def add_users_with_subscription(project, issuable)
@subscriber = create :user @subscriber = create :user
@unsubscriber = create :user @unsubscriber = create :user
......
module NotificationHelpers
extend self
def send_notifications(*new_mentions)
mentionable.description = new_mentions.map(&:to_reference).join(' ')
notification.send(notification_method, mentionable, new_mentions, @u_disabled)
end
def create_global_setting_for(user, level)
setting = user.global_notification_setting
setting.level = level
setting.save
user
end
def create_user_with_notification(level, username, resource = project)
user = create(:user, username: username)
setting = user.notification_settings_for(resource)
setting.level = level
setting.save
user
end
# Create custom notifications
# When resource is nil it means global notification
def update_custom_notification(event, user, resource: nil, value: true)
setting = user.notification_settings_for(resource)
setting.update!(event => value)
end
end
...@@ -197,3 +197,35 @@ end ...@@ -197,3 +197,35 @@ end
shared_examples 'an email with a labels subscriptions link in its footer' do shared_examples 'an email with a labels subscriptions link in its footer' do
it { is_expected.to have_body_text('label subscriptions') } it { is_expected.to have_body_text('label subscriptions') }
end end
shared_examples 'a note email' do
it_behaves_like 'it should have Gmail Actions links'
it 'is sent to the given recipient as the author' do
sender = subject.header[:from].addrs[0]
aggregate_failures do
expect(sender.display_name).to eq(note_author.name)
expect(sender.address).to eq(gitlab_sender)
expect(subject).to deliver_to(recipient.notification_email)
end
end
it 'contains the message from the note' do
is_expected.to have_html_escaped_body_text note.note
end
it 'does not contain note author' do
is_expected.not_to have_body_text note.author_name
end
context 'when enabled email_author_in_body' do
before do
stub_application_setting(email_author_in_body: true)
end
it 'contains a link to note author' do
is_expected.to have_html_escaped_body_text note.author_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