Add test notification tool (#28334)

* Add test notification tool

* Add frontend styles

* Remove option from admin view

* Refactor create post and add translations

* Fix several CI errors

* Fix API and frontend snapshots

* Refactor trailing and leading icon on buttons

* Add different button states

* i18n-extract

* Fix wrong text

* Add tests

* Fix wrong string

* Fix test

* feat: E2E send test notifications (#28371)

* Refactor send desktop notification

* Address rest of the feedback

* Fix tests

* Add correct link

* Fix test

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
Co-authored-by: yasserfaraazkhan <attitude3cena.yf@gmail.com>
This commit is contained in:
Daniel Espino García 2024-11-08 13:57:06 +01:00 committed by GitHub
parent 30a6ddc995
commit 118d0346ee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 1170 additions and 56 deletions

View File

@ -222,6 +222,30 @@
$ref: "#/components/responses/Forbidden"
"500":
$ref: "#/components/responses/InternalServerError"
/api/v4/notifications/test:
post:
tags:
- system
summary: Send a test notification
description: >
Send a test notification to make sure you have your notification settings
configured correctly.
##### Permissions
Must be logged in.
operationId: TestNotification
responses:
"200":
description: Notification successfully sent
content:
application/json:
schema:
$ref: "#/components/schemas/StatusOK"
"403":
$ref: "#/components/responses/Forbidden"
"500":
$ref: "#/components/responses/InternalServerError"
/api/v4/site_url/test:
post:
tags:

View File

@ -0,0 +1,128 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// ***************************************************************
// - [#] indicates a test step (e.g. # Go to a page)
// - [*] indicates an assertion (e.g. * Check the title)
// - Use element ID when selecting an element. Create one if none.
// ***************************************************************
// Stage: @prod
// Group: @channels @notification
describe('Verify users can receive notification on browser', () => {
let offTopic: string;
const notificationMessage = 'If you received this test notification, it worked!';
before(() => {
cy.apiInitSetup({userPrefix: 'other', loginAfter: true}).then(({offTopicUrl}) => {
offTopic = offTopicUrl;
});
});
it('MM-T5631_1 should be able to receive notification when notifications are enabled on the browser', () => {
cy.visit(offTopic);
cy.stubNotificationPermission('granted');
cy.get('#CustomizeYourExperienceTour > button').click();
triggertestNotification();
cy.get('@notificationStub').should('be.called');
cy.get('@notificationStub').should((stub) => {
expect(stub).to.have.been.calledWithMatch(
'Direct Message',
Cypress.sinon.match({
body: '@@system-bot: If you received this test notification, it worked!',
tag: '@@system-bot: If you received this test notification, it worked!',
requireInteraction: false,
silent: false,
}),
);
});
cy.get('#accountSettingsHeader button.close').click();
// * Verify user still recieves a message from system bot
cy.verifySystemBotMessageRecieved(notificationMessage);
});
it('MM-T5631_2 should not be able to receive notification when notifications are denied on the browser', () => {
cy.visit(offTopic);
cy.stubNotificationPermission('denied');
cy.get('#CustomizeYourExperienceTour > button').click();
triggertestNotification();
// * Assert that the Notification constructor was not called
cy.get('@notificationStub').should('not.be.called');
cy.get('#accountSettingsHeader button.close').click();
// * Verify user still recieves a message from system bot
cy.verifySystemBotMessageRecieved(notificationMessage);
});
it('MM-T5631_3 should not trigger notification when permission is default (no decision made)', () => {
cy.visit(offTopic);
cy.stubNotificationPermission('default');
cy.get('#CustomizeYourExperienceTour > button').click();
triggertestNotification();
// * Assert that the Notification constructor was not called
cy.get('@notificationStub').should('not.be.called');
cy.get('#accountSettingsHeader button.close').click();
// * Verify user still recieves a message from system bot
cy.verifySystemBotMessageRecieved(notificationMessage);
});
// Simulating macOS Focus Mode by suppressing the Notification constructor entirely
it('MM-T5631_4 should not show notification when Focus Mode is enabled (simulating no notification pop-up)', () => {
cy.visit(offTopic);
cy.stubNotificationPermission('granted');
cy.window().then((win) => {
win.Notification = function() {
// Do nothing to simulate Focus Mode
};
cy.stub(win, 'Notification').as('notificationStub').callsFake(() => {
return null; // Prevent the notification from being created
});
});
cy.get('#CustomizeYourExperienceTour > button').click();
triggertestNotification();
// * Assert that the Notification constructor was not called in Focus Mode
cy.get('@notificationStub').should('not.be.called');
cy.get('#accountSettingsHeader button.close').click();
// * Verify user still recieves a message from system bot
cy.verifySystemBotMessageRecieved(notificationMessage);
});
it('should still recieve a test notification when user has set Global and Channel Notification preference to Nothing', () => {
cy.visit(offTopic);
cy.stubNotificationPermission('default');
// # Mute Channel
cy.get('#channelHeaderTitle > span').click();
cy.get('#channelToggleMuteChannel').should('have.text', 'Mute Channel').click();
cy.get('#toggleMute').should('be.visible');
// # Set Desktop Notification preference to Nothing
cy.get('#CustomizeYourExperienceTour > button').click();
cy.get('#desktopAndMobileTitle').click();
cy.get('#sendDesktopNotificationsSection input[type=radio]').last().check();
cy.get('#saveSetting').click();
cy.wait(500);
triggertestNotification();
// * Assert that the Notification constructor was not called
cy.get('@notificationStub').should('not.be.called');
cy.get('#accountSettingsHeader button.close').click();
// * Verify user still recieves a message from system bot
cy.verifySystemBotMessageRecieved(notificationMessage);
});
});
function triggertestNotification() {
cy.get('.sectionNoticeContent').scrollIntoView().should('be.visible');
cy.get('.btn-tertiary').should('be.visible').should('have.text', 'Troubleshooting docs');
cy.get('.btn-primary').should('be.visible').should('have.text', 'Send a test notification').click();
}

View File

@ -28,6 +28,7 @@ import './fetch_commands';
import './keycloak_commands';
import './ldap_commands';
import './ldap_server_commands';
import './notification_commands';
import './okta_commands';
import './saml_commands';
import './shell';

View File

@ -0,0 +1,38 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import * as TIMEOUTS from '../fixtures/timeouts';
/**
* permission can be 'granted', 'denied', or 'default'
*/
function stubNotificationPermission(permission: string) {
cy.window().then((win) => {
cy.stub(win.Notification, 'permission').value(permission);
cy.stub(win.Notification, 'requestPermission').resolves(permission);
cy.stub(win, 'Notification').as('notificationStub').callsFake(() => {
return {
onclick: cy.stub().as('notificationOnClick'),
onerror: cy.stub().as('notificationOnError'),
};
});
});
}
/**
* Verify the system bot message was received
*/
function notificationMessage(notificationMessage: string) {
// * Assert the unread count is correct
cy.get('.SidebarLink:contains(system-bot)').find('#unreadMentions').as('unreadCount').should('be.visible').should('have.text', '1');
cy.get('.SidebarLink:contains(system-bot)').find('.Avatar').should('exist').click().wait(TIMEOUTS.HALF_SEC);
cy.get('@unreadCount').should('not.exist');
// * Assert the notification message
cy.getLastPostId().then((postId) => {
cy.get(`#postMessageText_${postId}`).scrollIntoView().should('be.visible').should('have.text', notificationMessage);
});
}
Cypress.Commands.add('stubNotificationPermission', stubNotificationPermission);
Cypress.Commands.add('verifySystemBotMessageRecieved', notificationMessage);

View File

@ -45,6 +45,7 @@ func (api *API) InitSystem() {
api.BaseRoutes.System.Handle("/timezones", api.APISessionRequired(getSupportedTimezones)).Methods(http.MethodGet)
api.BaseRoutes.APIRoot.Handle("/audits", api.APISessionRequired(getAudits)).Methods(http.MethodGet)
api.BaseRoutes.APIRoot.Handle("/notifications/test", api.APISessionRequired(testNotifications)).Methods(http.MethodPost)
api.BaseRoutes.APIRoot.Handle("/email/test", api.APISessionRequired(testEmail)).Methods(http.MethodPost)
api.BaseRoutes.APIRoot.Handle("/site_url/test", api.APISessionRequired(testSiteURL)).Methods(http.MethodPost)
api.BaseRoutes.APIRoot.Handle("/file/s3_test", api.APISessionRequired(testS3)).Methods(http.MethodPost)
@ -232,6 +233,16 @@ func getSystemPing(c *Context, w http.ResponseWriter, r *http.Request) {
}
}
func testNotifications(c *Context, w http.ResponseWriter, r *http.Request) {
_, err := c.App.SendTestMessage(c.AppContext, c.AppContext.Session().UserId)
if err != nil {
c.Err = err
return
}
ReturnStatusOK(w)
}
func testEmail(c *Context, w http.ResponseWriter, r *http.Request) {
var cfg *model.Config
err := json.NewDecoder(r.Body).Decode(&cfg)

View File

@ -1103,6 +1103,7 @@ type AppIface interface {
SendPasswordReset(rctx request.CTX, email string, siteURL string) (bool, *model.AppError)
SendPersistentNotifications() error
SendReportToUser(rctx request.CTX, job *model.Job, format string) *model.AppError
SendTestMessage(c request.CTX, userID string) (*model.Post, *model.AppError)
SendTestPushNotification(deviceID string) string
ServeInterPluginRequest(w http.ResponseWriter, r *http.Request, sourcePluginId, destinationPluginId string)
SessionHasPermissionTo(session model.Session, permission *model.Permission) bool

View File

@ -599,6 +599,10 @@ func (a *App) getMobileAppSessions(userID string) ([]*model.Session, *model.AppE
}
func (a *App) ShouldSendPushNotification(user *model.User, channelNotifyProps model.StringMap, wasMentioned bool, status *model.Status, post *model.Post, isGM bool) bool {
if prop := post.GetProp(model.PostPropsForceNotification); prop != nil && prop != "" {
return true
}
if notifyPropsAllowedReason := DoesNotifyPropsAllowPushNotification(user, channelNotifyProps, post, wasMentioned, isGM); notifyPropsAllowedReason != "" {
a.CountNotificationReason(model.NotificationStatusNotSent, model.NotificationTypePush, notifyPropsAllowedReason, model.NotificationNoPlatform)
a.NotificationsLog().Debug("Notification not sent - notify props",

View File

@ -1103,6 +1103,39 @@ func TestSendPushNotifications(t *testing.T) {
})
}
func TestShouldSendPushNotifications(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
t.Run("should return true if forced", func(t *testing.T) {
user := &model.User{Id: model.NewId(), Email: "unit@test.com", NotifyProps: make(map[string]string)}
user.NotifyProps[model.PushNotifyProp] = model.UserNotifyNone
post := &model.Post{UserId: user.Id, ChannelId: model.NewId()}
post.AddProp(model.PostPropsForceNotification, model.NewId())
channelNotifyProps := map[string]string{model.PushNotifyProp: model.ChannelNotifyNone, model.MarkUnreadNotifyProp: model.ChannelMarkUnreadMention}
status := &model.Status{UserId: user.Id, Status: model.StatusOnline, Manual: false, LastActivityAt: model.GetMillis(), ActiveChannel: post.ChannelId}
result := th.App.ShouldSendPushNotification(user, channelNotifyProps, false, status, post, false)
assert.True(t, result)
})
t.Run("should return false if force undefined", func(t *testing.T) {
user := &model.User{Id: model.NewId(), Email: "unit@test.com", NotifyProps: make(map[string]string)}
user.NotifyProps[model.PushNotifyProp] = model.UserNotifyNone
post := &model.Post{UserId: user.Id, ChannelId: model.NewId()}
channelNotifyProps := map[string]string{model.PushNotifyProp: model.ChannelNotifyNone, model.MarkUnreadNotifyProp: model.ChannelMarkUnreadMention}
status := &model.Status{UserId: user.Id, Status: model.StatusOnline, Manual: false, LastActivityAt: model.GetMillis(), ActiveChannel: post.ChannelId}
result := th.App.ShouldSendPushNotification(user, channelNotifyProps, false, status, post, false)
assert.False(t, result)
})
}
// testPushNotificationHandler is an HTTP handler to record push notifications
// being sent from the client.
// It records the number of requests sent to it, and stores all the requests

View File

@ -16398,6 +16398,28 @@ func (a *OpenTracingAppLayer) SendSubscriptionHistoryEvent(userID string) (*mode
return resultVar0, resultVar1
}
func (a *OpenTracingAppLayer) SendTestMessage(c request.CTX, userID string) (*model.Post, *model.AppError) {
origCtx := a.ctx
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.SendTestMessage")
a.ctx = newCtx
a.app.Srv().Store().SetContext(newCtx)
defer func() {
a.app.Srv().Store().SetContext(origCtx)
a.ctx = origCtx
}()
defer span.Finish()
resultVar0, resultVar1 := a.app.SendTestMessage(c, userID)
if resultVar1 != nil {
span.LogFields(spanlog.Error(resultVar1))
ext.Error.Set(span, true)
}
return resultVar0, resultVar1
}
func (a *OpenTracingAppLayer) SendTestPushNotification(deviceID string) string {
origCtx := a.ctx
span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.SendTestPushNotification")

View File

@ -242,6 +242,10 @@ func (a *App) CreatePost(c request.CTX, post *model.Post, channel *model.Channel
post.AddProp(model.PostPropsFromBot, "true")
}
if flags.ForceNotification {
post.AddProp(model.PostPropsForceNotification, model.NewId())
}
if c.Session().IsOAuth {
post.AddProp(model.PostPropsFromOAuthApp, "true")
}
@ -2722,3 +2726,34 @@ func (a *App) CleanUpAfterPostDeletion(c request.CTX, post *model.Post, deleteBy
return nil
}
func (a *App) SendTestMessage(c request.CTX, userID string) (*model.Post, *model.AppError) {
bot, err := a.GetSystemBot(c)
if err != nil {
return nil, model.NewAppError("SendTestMessage", "app.notifications.send_test_message.errors.no_bot", nil, "", http.StatusInternalServerError).Wrap(err)
}
channel, err := a.GetOrCreateDirectChannel(c, userID, bot.UserId)
if err != nil {
return nil, model.NewAppError("SendTestMessage", "app.notifications.send_test_message.errors.no_channel", nil, "", http.StatusInternalServerError).Wrap(err)
}
user, err := a.GetUser(userID)
if err != nil {
return nil, model.NewAppError("SendTestMessage", "app.notifications.send_test_message.errors.no_user", nil, "", http.StatusInternalServerError).Wrap(err)
}
T := i18n.GetUserTranslations(user.Locale)
post := &model.Post{
ChannelId: channel.Id,
Message: T("app.notifications.send_test_message.message_body"),
Type: model.PostTypeDefault,
UserId: bot.UserId,
}
post, err = a.CreatePost(c, post, channel, model.CreatePostFlags{ForceNotification: true})
if err != nil {
return nil, model.NewAppError("SendTestMessage", "app.notifications.send_test_message.errors.create_post", nil, "", http.StatusInternalServerError).Wrap(err)
}
return post, nil
}

View File

@ -1178,6 +1178,37 @@ func TestCreatePost(t *testing.T) {
wg.Wait()
})
t.Run("should sanitize the force notifications prop if the flag is not set", func(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
th.AddUserToChannel(th.BasicUser, th.BasicChannel)
postToCreate := &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "hello world",
UserId: th.BasicUser.Id,
}
postToCreate.AddProp(model.PostPropsForceNotification, model.NewId())
createdPost, err := th.App.CreatePost(th.Context, postToCreate, th.BasicChannel, model.CreatePostFlags{})
require.Nil(t, err)
require.Empty(t, createdPost.GetProp(model.PostPropsForceNotification))
})
t.Run("should add the force notifications prop if the flag is set", func(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
th.AddUserToChannel(th.BasicUser, th.BasicChannel)
postToCreate := &model.Post{
ChannelId: th.BasicChannel.Id,
Message: "hello world",
UserId: th.BasicUser.Id,
}
createdPost, err := th.App.CreatePost(th.Context, postToCreate, th.BasicChannel, model.CreatePostFlags{ForceNotification: true})
require.Nil(t, err)
require.NotEmpty(t, createdPost.GetProp(model.PostPropsForceNotification))
})
}
func TestPatchPost(t *testing.T) {
@ -3739,3 +3770,13 @@ func TestPermanentDeletePost(t *testing.T) {
assert.Len(t, infos, 0)
})
}
func TestSendTestMessage(t *testing.T) {
th := Setup(t).InitBasic()
defer th.TearDown()
t.Run("Should create the post with the correct prop", func(t *testing.T) {
post, result := th.App.SendTestMessage(th.Context, th.BasicUser.Id)
assert.Nil(t, result)
assert.NotEmpty(t, post.GetProp(model.PostPropsForceNotification))
})
}

View File

@ -5938,6 +5938,26 @@
"id": "app.notification.subject.notification.full",
"translation": "[{{ .SiteName }}] Notification in {{ .TeamName}} on {{.Month}} {{.Day}}, {{.Year}}"
},
{
"id": "app.notifications.send_test_message.errors.create_post",
"translation": "The post cannot be created"
},
{
"id": "app.notifications.send_test_message.errors.no_bot",
"translation": "Cannot get the system bot"
},
{
"id": "app.notifications.send_test_message.errors.no_channel",
"translation": "Cannot get the system bot direct message"
},
{
"id": "app.notifications.send_test_message.errors.no_user",
"translation": "Cannot get the user"
},
{
"id": "app.notifications.send_test_message.message_body",
"translation": "If you received this test notification, it worked!"
},
{
"id": "app.notify_admin.save.app_error",
"translation": "Unable to save notify data."

View File

@ -344,6 +344,10 @@ func (c *Client4) testEmailRoute() string {
return "/email/test"
}
func (c *Client4) testNotificationRoute() string {
return "/notifications/test"
}
func (c *Client4) usageRoute() string {
return "/usage"
}
@ -4820,6 +4824,15 @@ func (c *Client4) TestEmail(ctx context.Context, config *Config) (*Response, err
return BuildResponse(r), nil
}
func (c *Client4) TestNotifications(ctx context.Context) (*Response, error) {
r, err := c.DoAPIPost(ctx, c.testNotificationRoute(), "")
if err != nil {
return BuildResponse(r), err
}
defer closeBody(r)
return BuildResponse(r), nil
}
// TestSiteURL will test the validity of a site URL.
func (c *Client4) TestSiteURL(ctx context.Context, siteURL string) (*Response, error) {
requestBody := make(map[string]string)

View File

@ -75,6 +75,7 @@ const (
PostPropsMentionHighlightDisabled = "mentionHighlightDisabled"
PostPropsGroupHighlightDisabled = "disable_group_highlight"
PostPropsPreviewedPost = "previewed_post"
PostPropsForceNotification = "force_notification"
PostPriorityUrgent = "urgent"
PostPropsRequestedAck = "requested_ack"
@ -341,6 +342,7 @@ func (o *Post) EncodeJSON(w io.Writer) error {
type CreatePostFlags struct {
TriggerWebhooks bool
SetOnline bool
ForceNotification bool
}
type GetPostsSinceOptions struct {
@ -500,6 +502,7 @@ func (o *Post) SanitizeProps() {
}
membersToSanitize := []string{
PropsAddChannelMember,
PostPropsForceNotification,
}
for _, member := range membersToSanitize {

View File

@ -115,22 +115,26 @@ func TestPostSanitizeProps(t *testing.T) {
post1.SanitizeProps()
require.Nil(t, post1.GetProp(PropsAddChannelMember))
require.Nil(t, post1.GetProp(PostPropsForceNotification))
post2 := &Post{
Message: "test",
Props: StringInterface{
PropsAddChannelMember: "test",
PostPropsForceNotification: "test",
},
}
post2.SanitizeProps()
require.Nil(t, post2.GetProp(PropsAddChannelMember))
require.Nil(t, post2.GetProp(PostPropsForceNotification))
post3 := &Post{
Message: "test",
Props: StringInterface{
PropsAddChannelMember: "no good",
PostPropsForceNotification: "no good",
"attachments": "good",
},
}
@ -138,6 +142,7 @@ func TestPostSanitizeProps(t *testing.T) {
post3.SanitizeProps()
require.Nil(t, post3.GetProp(PropsAddChannelMember))
require.Nil(t, post3.GetProp(PostPropsForceNotification))
require.NotNil(t, post3.GetProp("attachments"))
}

View File

@ -331,6 +331,34 @@ describe('notification_actions', () => {
});
});
test('should notify for forced notification posts on muted channels', () => {
const store = testConfigureStore(baseState);
const newPost = {
...post,
props: {
...post.props,
force_notification: 'test',
},
};
newPost.channel_id = 'muted_channel_id';
const newMsgProps = {
post: JSON.stringify(newPost),
channel_display_name: 'Muted Channel',
team_id: 'team_id',
};
return store.dispatch(sendDesktopNotification(newPost, newMsgProps)).then((result) => {
expect(result).toEqual({data: {status: 'success'}});
expect(spy).toHaveBeenCalledWith({
body: '@username: Where is Jessica Hyde?',
requireInteraction: false,
silent: false,
title: 'Muted Channel',
onClick: expect.any(Function),
});
});
});
test.each([
UserStatuses.DND,
UserStatuses.OUT_OF_OFFICE,

View File

@ -8,6 +8,7 @@ import type {Post} from '@mattermost/types/posts';
import type {UserProfile} from '@mattermost/types/users';
import {logError} from 'mattermost-redux/actions/errors';
import {Client4} from 'mattermost-redux/client';
import {getCurrentChannel, getMyChannelMember, makeGetChannel} from 'mattermost-redux/selectors/entities/channels';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {
@ -117,6 +118,7 @@ export function sendDesktopNotification(post: Post, msgProps: NewPostMessageProp
const user = getCurrentUser(state);
const member = getMyChannelMember(state, post.channel_id);
const isCrtReply = isCollapsedThreadsEnabled(state) && post.root_id !== '';
const forceNotification = Boolean(post.props?.force_notification);
const skipNotificationReason = shouldSkipNotification(
state,
@ -125,6 +127,7 @@ export function sendDesktopNotification(post: Post, msgProps: NewPostMessageProp
user,
channel,
member,
forceNotification,
isCrtReply,
);
if (skipNotificationReason) {
@ -156,7 +159,7 @@ export function sendDesktopNotification(post: Post, msgProps: NewPostMessageProp
const argsAfterHooks = hookResult.data!;
if (!argsAfterHooks.notify) {
if (!argsAfterHooks.notify && !forceNotification) {
return {data: {status: 'not_sent', reason: 'desktop_notification_hook', data: String(hookResult)}};
}
@ -254,6 +257,7 @@ function shouldSkipNotification(
user: UserProfile,
channel: Pick<Channel, 'type' | 'id'>,
member: ChannelMembership | undefined,
skipChecks: boolean,
isCrtReply: boolean,
) {
const currentUserId = getCurrentUserId(state);
@ -269,6 +273,10 @@ function shouldSkipNotification(
return {status: 'error', reason: 'no_member'};
}
if (skipChecks) {
return undefined;
}
if (isChannelMuted(member)) {
return {status: 'not_sent', reason: 'channel_muted'};
}
@ -428,3 +436,12 @@ export function notifyMe(title: string, body: string, channelId: string, teamId:
}
};
}
export const sendTestNotification = async () => {
try {
const result = await Client4.sendTestNotificaiton();
return result;
} catch (error) {
return error;
}
};

View File

@ -0,0 +1,132 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {DeepPartial} from '@mattermost/types/utilities';
import {renderHookWithContext} from 'tests/react_testing_utils';
import type {GlobalState} from 'types/store';
import {useExternalLink} from './use_external_link';
const baseCurrentUserId = 'someUserId';
const baseTelemetryId = 'someTelemetryId';
function getBaseState(): DeepPartial<GlobalState> {
return {
entities: {
users: {
currentUserId: baseCurrentUserId,
},
general: {
config: {
TelemetryId: baseTelemetryId,
},
license: {
Cloud: 'true',
},
},
},
};
}
describe('useExternalLink', () => {
it('keep non mattermost links untouched', () => {
const url = 'https://www.someLink.com/something?query1=2#anchor';
const {result: {current: [href, queryParams]}} = renderHookWithContext(() => useExternalLink(url, 'some location', {utm_source: 'something'}), getBaseState());
expect(href).toEqual(url);
expect(queryParams).toEqual({});
});
it('all base queries are set correctly', () => {
const url = 'https://www.mattermost.com/some/url';
const {result: {current: [href, queryParams]}} = renderHookWithContext(() => useExternalLink(url), getBaseState());
const parsedLink = new URL(href);
expect(parsedLink.searchParams.get('utm_source')).toBe('mattermost');
expect(parsedLink.searchParams.get('utm_medium')).toBe('in-product-cloud');
expect(parsedLink.searchParams.get('utm_content')).toBe('');
expect(parsedLink.searchParams.get('uid')).toBe(baseCurrentUserId);
expect(parsedLink.searchParams.get('sid')).toBe(baseTelemetryId);
expect(queryParams.utm_source).toBe('mattermost');
expect(queryParams.utm_medium).toBe('in-product-cloud');
expect(queryParams.utm_content).toBe('');
expect(queryParams.uid).toBe(baseCurrentUserId);
expect(queryParams.sid).toBe(baseTelemetryId);
expect(href.split('?')[0]).toBe(url);
});
it('provided location is added to the params', () => {
const url = 'https://www.mattermost.com/some/url';
const location = 'someLocation';
const {result: {current: [href, queryParams]}} = renderHookWithContext(() => useExternalLink(url, location), getBaseState());
const parsedLink = new URL(href);
expect(parsedLink.searchParams.get('utm_content')).toBe(location);
expect(queryParams.utm_content).toBe(location);
});
it('non cloud environments set the proper utm medium', () => {
const url = 'https://www.mattermost.com/some/url';
const state = getBaseState();
state.entities!.general!.license!.Cloud = 'false';
const {result: {current: [href, queryParams]}} = renderHookWithContext(() => useExternalLink(url), state);
const parsedLink = new URL(href);
expect(parsedLink.searchParams.get('utm_medium')).toBe('in-product');
expect(queryParams.utm_medium).toBe('in-product');
});
it('keep existing query parameters untouched', () => {
const url = 'https://www.mattermost.com/some/url?myParameter=true';
const {result: {current: [href, queryParams]}} = renderHookWithContext(() => useExternalLink(url), getBaseState());
const parsedLink = new URL(href);
expect(parsedLink.searchParams.get('myParameter')).toBe('true');
expect(queryParams.myParameter).toBe('true');
});
it('keep anchors untouched', () => {
const url = 'https://www.mattermost.com/some/url?myParameter=true#myAnchor';
const {result: {current: [href]}} = renderHookWithContext(() => useExternalLink(url), getBaseState());
const parsedLink = new URL(href);
expect(parsedLink.hash).toBe('#myAnchor');
});
it('overwriting params gets preference over default params', () => {
const url = 'https://www.mattermost.com/some/url';
const location = 'someLocation';
const expectedContent = 'someOtherLocation';
const expectedSource = 'someOtherSource';
const {result: {current: [href, queryParams]}} = renderHookWithContext(() => useExternalLink(url, location, {utm_content: expectedContent, utm_source: expectedSource}), getBaseState());
const parsedLink = new URL(href);
expect(parsedLink.searchParams.get('utm_content')).toBe(expectedContent);
expect(queryParams.utm_content).toBe(expectedContent);
expect(parsedLink.searchParams.get('utm_source')).toBe(expectedSource);
expect(queryParams.utm_source).toBe(expectedSource);
});
it('existing params gets preference over default and overwritten params', () => {
const location = 'someLocation';
const overwrittenContent = 'someOtherLocation';
const overwrittenSource = 'someOtherSource';
const expectedContent = 'differentLocation';
const expectedSource = 'differentSource';
const url = `https://www.mattermost.com/some/url?utm_content=${expectedContent}&utm_source=${expectedSource}`;
const {result: {current: [href, queryParams]}} = renderHookWithContext(() => useExternalLink(url, location, {utm_content: overwrittenContent, utm_source: overwrittenSource}), getBaseState());
const parsedLink = new URL(href);
expect(parsedLink.searchParams.get('utm_content')).toBe(expectedContent);
expect(queryParams.utm_content).toBe(expectedContent);
expect(parsedLink.searchParams.get('utm_source')).toBe(expectedSource);
expect(queryParams.utm_source).toBe(expectedSource);
});
it('results are stable between re-renders', () => {
const url = 'https://www.mattermost.com/some/url';
const overwriteQueryParams = {utm_content: 'overwrittenContent', utm_source: 'overwrittenSource'};
const {result, rerender} = renderHookWithContext(() => useExternalLink(url, 'someLocation', overwriteQueryParams), getBaseState());
const [firstHref, firstParams] = result.current;
rerender();
const [secondHref, secondParams] = result.current;
expect(firstHref).toBe(secondHref);
expect(firstParams).toBe(secondParams);
});
});

View File

@ -0,0 +1,47 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useMemo} from 'react';
import {useSelector} from 'react-redux';
import {getConfig, getLicense} from 'mattermost-redux/selectors/entities/general';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import type {GlobalState} from 'types/store';
export type ExternalLinkQueryParams = {
utm_source?: string;
utm_medium?: string;
utm_campaign?: string;
utm_content?: string;
userId?: string;
}
export function useExternalLink(href: string, location: string = '', overwriteQueryParams: ExternalLinkQueryParams = {}): [string, Record<string, string>] {
const userId = useSelector(getCurrentUserId);
const telemetryId = useSelector((state: GlobalState) => getConfig(state).TelemetryId || '');
const isCloud = useSelector((state: GlobalState) => getLicense(state).Cloud === 'true');
return useMemo(() => {
if (!href?.includes('mattermost.com')) {
return [href, {}];
}
const parsedUrl = new URL(href);
const existingURLSearchParams = parsedUrl.searchParams;
const existingQueryParamsObj = Object.fromEntries(existingURLSearchParams.entries());
const queryParams = {
utm_source: 'mattermost',
utm_medium: isCloud ? 'in-product-cloud' : 'in-product',
utm_content: location,
uid: userId,
sid: telemetryId,
...overwriteQueryParams,
...existingQueryParamsObj,
};
parsedUrl.search = new URLSearchParams(queryParams).toString();
return [parsedUrl.toString(), queryParams];
}, [href, isCloud, location, overwriteQueryParams, telemetryId, userId]);
}

View File

@ -18,7 +18,7 @@ exports[`components/external_link should match snapshot 1`] = `
location="test"
>
<a
href="https://mattermost.com?utm_source=mattermost&utm_medium=in-product-cloud&utm_content=test&uid=currentUserId&sid="
href="https://mattermost.com/?utm_source=mattermost&utm_medium=in-product-cloud&utm_content=test&uid=currentUserId&sid="
location="test"
onClick={[Function]}
rel="noopener noreferrer"

View File

@ -98,7 +98,7 @@ describe('components/external_link', () => {
expect(screen.queryByText('Click Me')).toHaveAttribute(
'href',
'https://mattermost.com?utm_source=mattermost&utm_medium=in-product-cloud&utm_content=test&uid=currentUserId&sid=&test=true',
'https://mattermost.com/?utm_source=mattermost&utm_medium=in-product-cloud&utm_content=test&uid=currentUserId&sid=&test=true',
);
});
@ -191,7 +191,7 @@ describe('components/external_link', () => {
expect(screen.queryByText('Click Me')).toHaveAttribute(
'href',
'https://mattermost.com?utm_source=mattermost&utm_medium=in-product-cloud&utm_content=test&uid=currentUserId&sid=#desktop',
'https://mattermost.com/?utm_source=mattermost&utm_medium=in-product-cloud&utm_content=test&uid=currentUserId&sid=#desktop',
);
});
});

View File

@ -4,20 +4,11 @@
/* eslint-disable @mattermost/use-external-link */
import React, {forwardRef} from 'react';
import {useSelector} from 'react-redux';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/common';
import {getConfig, getLicense} from 'mattermost-redux/selectors/entities/general';
import {trackEvent} from 'actions/telemetry_actions';
type ExternalLinkQueryParams = {
utm_source?: string;
utm_medium?: string;
utm_campaign?: string;
utm_content?: string;
userId?: string;
}
import type {ExternalLinkQueryParams} from 'components/common/hooks/use_external_link';
import {useExternalLink} from 'components/common/hooks/use_external_link';
type Props = React.AnchorHTMLAttributes<HTMLAnchorElement> & {
href: string;
@ -30,35 +21,7 @@ type Props = React.AnchorHTMLAttributes<HTMLAnchorElement> & {
}
const ExternalLink = forwardRef<HTMLAnchorElement, Props>((props, ref) => {
const userId = useSelector(getCurrentUserId);
const config = useSelector(getConfig);
const license = useSelector(getLicense);
let href = props.href;
let queryParams = {};
if (href?.includes('mattermost.com')) {
const existingURLSearchParams = new URL(href).searchParams;
const existingQueryParamsObj = Object.fromEntries(existingURLSearchParams.entries());
queryParams = {
utm_source: 'mattermost',
utm_medium: license.Cloud === 'true' ? 'in-product-cloud' : 'in-product',
utm_content: props.location || '',
uid: userId,
sid: config.TelemetryId || '',
...props.queryParams,
...existingQueryParamsObj,
};
const queryString = new URLSearchParams(queryParams).toString();
if (Object.keys(existingQueryParamsObj).length) {
// If the href already has query params, remove them before adding them back with the addition of the new ones
href = href?.split('?')[0];
}
const anchor = new URL(href).hash;
if (anchor) {
href = href.replace(anchor, '');
}
href = `${href}?${queryString}${anchor ?? ''}`;
}
const [href, queryParams] = useExternalLink(props.href, props.location, props.queryParams);
const handleClick = (e: React.MouseEvent<HTMLElement>) => {
trackEvent('link_out', 'click_external_link', queryParams);

View File

@ -93,6 +93,7 @@ describe('components/MarketplaceItemPlugin', () => {
entities: {
general: {
config: {},
license: {},
},
users: {
currentUserId: 'currentUserId',

View File

@ -0,0 +1,10 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
type SectionNoticeButton = {
onClick: () => void;
text: string;
trailingIcon?: string;
leadingIcon?: string;
loading?: boolean;
}

View File

@ -0,0 +1,53 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/user_settings/notifications/send_test_notification_notice should match snapshot 1`] = `
<div>
<div
class="divider-light"
/>
<div
style="margin-top: 20px;"
>
<div
class="sectionNoticeContainer hint"
>
<div
class="sectionNoticeContent"
>
<i
class="icon sectionNoticeIcon icon-lightbulb-outline hint"
/>
<div
class="sectionNoticeBody"
>
<h4
class="sectionNoticeTitle"
>
Troubleshooting notifications
</h4>
<p>
Not receiving notifications? Start by sending a test notification to all your devices to check if theyre working as expected. If issues persist, explore ways to solve them with troubleshooting steps.
</p>
<div
class="sectionNoticeActions"
>
<button
class="btn btn-sm sectionNoticeButton btn-primary"
>
Send a test notification
</button>
<button
class="btn btn-sm sectionNoticeButton btn-tertiary"
>
Troubleshooting docs
<i
class="icon icon-open-in-new"
/>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
`;

View File

@ -258,8 +258,52 @@ Object {
class="divider-light"
/>
<div
class="divider-dark"
class="divider-light"
/>
<div
style="margin-top: 20px;"
>
<div
class="sectionNoticeContainer hint"
>
<div
class="sectionNoticeContent"
>
<i
class="icon sectionNoticeIcon icon-lightbulb-outline hint"
/>
<div
class="sectionNoticeBody"
>
<h4
class="sectionNoticeTitle"
>
Troubleshooting notifications
</h4>
<p>
Not receiving notifications? Start by sending a test notification to all your devices to check if theyre working as expected. If issues persist, explore ways to solve them with troubleshooting steps.
</p>
<div
class="sectionNoticeActions"
>
<button
class="btn btn-sm sectionNoticeButton btn-primary"
>
Send a test notification
</button>
<button
class="btn btn-sm sectionNoticeButton btn-tertiary"
>
Troubleshooting docs
<i
class="icon icon-open-in-new"
/>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@ -518,8 +562,52 @@ Object {
class="divider-light"
/>
<div
class="divider-dark"
class="divider-light"
/>
<div
style="margin-top: 20px;"
>
<div
class="sectionNoticeContainer hint"
>
<div
class="sectionNoticeContent"
>
<i
class="icon sectionNoticeIcon icon-lightbulb-outline hint"
/>
<div
class="sectionNoticeBody"
>
<h4
class="sectionNoticeTitle"
>
Troubleshooting notifications
</h4>
<p>
Not receiving notifications? Start by sending a test notification to all your devices to check if theyre working as expected. If issues persist, explore ways to solve them with troubleshooting steps.
</p>
<div
class="sectionNoticeActions"
>
<button
class="btn btn-sm sectionNoticeButton btn-primary"
>
Send a test notification
</button>
<button
class="btn btn-sm sectionNoticeButton btn-tertiary"
>
Troubleshooting docs
<i
class="icon icon-open-in-new"
/>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>,
@ -840,8 +928,52 @@ Object {
</div>
</div>
<div
class="divider-dark"
class="divider-light"
/>
<div
style="margin-top: 20px;"
>
<div
class="sectionNoticeContainer hint"
>
<div
class="sectionNoticeContent"
>
<i
class="icon sectionNoticeIcon icon-lightbulb-outline hint"
/>
<div
class="sectionNoticeBody"
>
<h4
class="sectionNoticeTitle"
>
Troubleshooting notifications
</h4>
<p>
Not receiving notifications? Start by sending a test notification to all your devices to check if theyre working as expected. If issues persist, explore ways to solve them with troubleshooting steps.
</p>
<div
class="sectionNoticeActions"
>
<button
class="btn btn-sm sectionNoticeButton btn-primary"
>
Send a test notification
</button>
<button
class="btn btn-sm sectionNoticeButton btn-tertiary"
>
Troubleshooting docs
<i
class="icon icon-open-in-new"
/>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@ -1103,8 +1235,52 @@ Object {
</div>
</div>
<div
class="divider-dark"
class="divider-light"
/>
<div
style="margin-top: 20px;"
>
<div
class="sectionNoticeContainer hint"
>
<div
class="sectionNoticeContent"
>
<i
class="icon sectionNoticeIcon icon-lightbulb-outline hint"
/>
<div
class="sectionNoticeBody"
>
<h4
class="sectionNoticeTitle"
>
Troubleshooting notifications
</h4>
<p>
Not receiving notifications? Start by sending a test notification to all your devices to check if theyre working as expected. If issues persist, explore ways to solve them with troubleshooting steps.
</p>
<div
class="sectionNoticeActions"
>
<button
class="btn btn-sm sectionNoticeButton btn-primary"
>
Send a test notification
</button>
<button
class="btn btn-sm sectionNoticeButton btn-tertiary"
>
Troubleshooting docs
<i
class="icon icon-open-in-new"
/>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>,
@ -1387,8 +1563,52 @@ Object {
class="divider-light"
/>
<div
class="divider-dark"
class="divider-light"
/>
<div
style="margin-top: 20px;"
>
<div
class="sectionNoticeContainer hint"
>
<div
class="sectionNoticeContent"
>
<i
class="icon sectionNoticeIcon icon-lightbulb-outline hint"
/>
<div
class="sectionNoticeBody"
>
<h4
class="sectionNoticeTitle"
>
Troubleshooting notifications
</h4>
<p>
Not receiving notifications? Start by sending a test notification to all your devices to check if theyre working as expected. If issues persist, explore ways to solve them with troubleshooting steps.
</p>
<div
class="sectionNoticeActions"
>
<button
class="btn btn-sm sectionNoticeButton btn-primary"
>
Send a test notification
</button>
<button
class="btn btn-sm sectionNoticeButton btn-tertiary"
>
Troubleshooting docs
<i
class="icon icon-open-in-new"
/>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@ -1612,8 +1832,52 @@ Object {
class="divider-light"
/>
<div
class="divider-dark"
class="divider-light"
/>
<div
style="margin-top: 20px;"
>
<div
class="sectionNoticeContainer hint"
>
<div
class="sectionNoticeContent"
>
<i
class="icon sectionNoticeIcon icon-lightbulb-outline hint"
/>
<div
class="sectionNoticeBody"
>
<h4
class="sectionNoticeTitle"
>
Troubleshooting notifications
</h4>
<p>
Not receiving notifications? Start by sending a test notification to all your devices to check if theyre working as expected. If issues persist, explore ways to solve them with troubleshooting steps.
</p>
<div
class="sectionNoticeActions"
>
<button
class="btn btn-sm sectionNoticeButton btn-primary"
>
Send a test notification
</button>
<button
class="btn btn-sm sectionNoticeButton btn-tertiary"
>
Troubleshooting docs
<i
class="icon icon-open-in-new"
/>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>,

View File

@ -0,0 +1,67 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {sendTestNotification} from 'actions/notification_actions';
import {act, renderWithContext, screen, waitFor} from 'tests/react_testing_utils';
import SendTestNotificationNotice from './send_test_notification_notice';
jest.mock('actions/notification_actions', () => ({
sendTestNotification: jest.fn().mockResolvedValue({status: 'OK'}),
}));
const mockedSendTestNotification = jest.mocked(sendTestNotification);
describe('components/user_settings/notifications/send_test_notification_notice', () => {
jest.useFakeTimers();
it('should match snapshot', () => {
const {container} = renderWithContext((<SendTestNotificationNotice/>));
expect(container).toMatchSnapshot();
});
it('should not show on admin mode', () => {
const {container} = renderWithContext((<SendTestNotificationNotice adminMode={true}/>));
expect(container).toBeEmptyDOMElement();
});
it('should send the notificaton when the send button is clicked', async () => {
renderWithContext((<SendTestNotificationNotice/>));
expect(mockedSendTestNotification).not.toHaveBeenCalled();
act(() => screen.getByText('Send a test notification').click());
await waitFor(() => {
expect(mockedSendTestNotification).toHaveBeenCalled();
expect(screen.getByText('Test notification sent')).toBeInTheDocument();
});
});
it('should open link when the secondary button is clicked', () => {
const originalOpen = window.open;
const mockedOpen = jest.fn();
window.open = mockedOpen;
renderWithContext((<SendTestNotificationNotice/>));
expect(mockedOpen).not.toHaveBeenCalled();
act(() => screen.getByText('Troubleshooting docs').click());
expect(mockedOpen).toHaveBeenCalled();
window.open = originalOpen;
});
it('should show error on button when the system returns an error', async () => {
mockedSendTestNotification.mockResolvedValueOnce({status: 'NOT OK'});
const originalConsole = console.error;
const mockedConsole = jest.fn();
console.error = mockedConsole;
renderWithContext((<SendTestNotificationNotice/>));
expect(mockedSendTestNotification).not.toHaveBeenCalled();
act(() => screen.getByText('Send a test notification').click());
await waitFor(() => {
expect(mockedSendTestNotification).toHaveBeenCalled();
expect(screen.getByText('Error sending test notification')).toBeInTheDocument();
expect(mockedConsole).toHaveBeenCalledWith({status: 'NOT OK'});
});
console.error = originalConsole;
});
});

View File

@ -0,0 +1,138 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {useIntl} from 'react-intl';
import {sendTestNotification} from 'actions/notification_actions';
import {useExternalLink} from 'components/common/hooks/use_external_link';
import SectionNotice from 'components/section_notice';
const sectionNoticeContainerStyle: React.CSSProperties = {marginTop: 20};
const TIME_TO_SENDING = 500;
const TIME_TO_SEND = 500;
const TIME_TO_IDLE = 3000;
type Props = {
adminMode?: boolean;
};
type ButtonState = 'idle'|'sending'|'sent'|'error';
const SendTestNotificationNotice = ({
adminMode = false,
}: Props) => {
const intl = useIntl();
const [buttonState, setButtonState] = useState<ButtonState>('idle');
const isSending = useRef(false);
const timeout = useRef<NodeJS.Timeout>();
const [externalLink] = useExternalLink('https://mattermost.com/pl/troubleshoot-notifications');
const onGoToNotificationDocumentation = useCallback(() => {
window.open(externalLink);
}, [externalLink]);
const onSendTestNotificationClick = useCallback(async () => {
if (isSending.current) {
return;
}
isSending.current = true;
let isShowingSending = false;
timeout.current = setTimeout(() => {
isShowingSending = true;
setButtonState('sending');
}, TIME_TO_SENDING);
const result = await sendTestNotification();
clearTimeout(timeout.current);
const setResult = () => {
if (result.status === 'OK') {
setButtonState('sent');
} else {
// We want to log this error into the console mainly
// for debugging reasons. We still use the 'error' level
// because it is an unexpected error.
// eslint-disable-next-line no-console
console.error(result);
setButtonState('error');
}
timeout.current = setTimeout(() => {
isSending.current = false;
setButtonState('idle');
}, TIME_TO_IDLE);
};
if (isShowingSending) {
timeout.current = setTimeout(setResult, TIME_TO_SEND);
} else {
setResult();
}
}, []);
useEffect(() => {
return () => {
clearTimeout(timeout.current);
};
}, []);
const primaryButton = useMemo(() => {
let text;
let icon;
let loading;
switch (buttonState) {
case 'idle':
text = intl.formatMessage({id: 'user_settings.notifications.test_notification.send_button.send', defaultMessage: 'Send a test notification'});
break;
case 'sending':
text = intl.formatMessage({id: 'user_settings.notifications.test_notification.send_button.sending', defaultMessage: 'Sending a test notification'});
loading = true;
break;
case 'sent':
text = intl.formatMessage({id: 'user_settings.notifications.test_notification.send_button.sent', defaultMessage: 'Test notification sent'});
icon = 'icon-check';
break;
case 'error':
text = intl.formatMessage({id: 'user_settings.notifications.test_notification.send_button.error', defaultMessage: 'Error sending test notification'});
icon = 'icon-alert-outline';
}
return {
onClick: onSendTestNotificationClick,
text,
leadingIcon: icon,
loading,
};
}, [buttonState, intl, onSendTestNotificationClick]);
const secondaryButton = useMemo(() => {
return {
onClick: onGoToNotificationDocumentation,
text: intl.formatMessage({id: 'user_settings.notifications.test_notification.go_to_docs', defaultMessage: 'Troubleshooting docs'}),
trailingIcon: 'icon-open-in-new',
};
}, [intl, onGoToNotificationDocumentation]);
if (adminMode) {
return null;
}
return (
<>
<div className='divider-light'/>
<div style={sectionNoticeContainerStyle}>
<SectionNotice
text={intl.formatMessage({
id: 'user_settings.notifications.test_notification.body',
defaultMessage: 'Not receiving notifications? Start by sending a test notification to all your devices to check if theyre working as expected. If issues persist, explore ways to solve them with troubleshooting steps.',
})}
title={intl.formatMessage({id: 'user_settings.notifications.test_notification.title', defaultMessage: 'Troubleshooting notifications'})}
primaryButton={primaryButton}
tertiaryButton={secondaryButton}
type='hint'
/>
</div>
</>
);
};
export default SendTestNotificationNotice;

View File

@ -31,6 +31,7 @@ import DesktopAndMobileNotificationSettings from './desktop_and_mobile_notificat
import DesktopNotificationSoundsSettings from './desktop_notification_sounds_setting';
import EmailNotificationSetting from './email_notification_setting';
import ManageAutoResponder from './manage_auto_responder/manage_auto_responder';
import SendTestNotificationNotice from './send_test_notification_notice';
import SettingDesktopHeader from '../headers/setting_desktop_header';
import SettingMobileHeader from '../headers/setting_mobile_header';
@ -1099,7 +1100,7 @@ class NotificationsTab extends React.PureComponent<Props, State> {
{keywordsWithHighlightSection}
</>
)}
<div className='divider-dark'/>
<SendTestNotificationNotice adminMode={this.props.adminMode}/>
</div>
</div>

View File

@ -5484,6 +5484,13 @@
"user_profile.roleTitle.team_admin": "Team Admin",
"user_profile.send.dm": "Message",
"user_profile.send.dm.yourself": "Send yourself a message",
"user_settings.notifications.test_notification.body": "Not receiving notifications? Start by sending a test notification to all your devices to check if theyre working as expected. If issues persist, explore ways to solve them with troubleshooting steps.",
"user_settings.notifications.test_notification.go_to_docs": "Troubleshooting docs",
"user_settings.notifications.test_notification.send_button.error": "Error sending test notification",
"user_settings.notifications.test_notification.send_button.send": "Send a test notification",
"user_settings.notifications.test_notification.send_button.sending": "Sending a test notification",
"user_settings.notifications.test_notification.send_button.sent": "Test notification sent",
"user_settings.notifications.test_notification.title": "Troubleshooting notifications",
"user.settings.advance.confirmDeactivateAccountTitle": "Confirm Deactivation",
"user.settings.advance.confirmDeactivateDesc": "Are you sure you want to deactivate your account? This can only be reversed by your System Administrator.",
"user.settings.advance.deactivate_member_modal.deactivateButton": "Yes, deactivate my account",

View File

@ -3292,6 +3292,13 @@ export default class Client4 {
);
};
sendTestNotificaiton = () => {
return this.doFetch<StatusOK>(
`${this.getBaseRoute()}/notifications/test`,
{method: 'post'},
);
};
testEmail = (config?: AdminConfig) => {
return this.doFetch<StatusOK>(
`${this.getBaseRoute()}/email/test`,