Add test notification tool (#8271)

* Add test notification menu

* Reduce the minimum version for testing purposes

* Address feedback

* Add missing strings

* Address feedback

* Fix snapshots

* Bump version limit and use correct link

* Fix tests

* Fix URL issues
This commit is contained in:
Daniel Espino García 2024-11-11 10:22:57 +01:00 committed by GitHub
parent 83189afa88
commit e0992c0bf6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1935 additions and 8 deletions

View file

@ -14,6 +14,7 @@ import {
fetchNotificationData,
backgroundNotification,
openNotification,
sendTestNotification,
} from './notifications';
import type ServerDataOperator from '@database/operator/server_data_operator';
@ -83,12 +84,18 @@ const mockClient = {
getTeamMember: jest.fn((id: string, userId: string) => ({id: userId + '-' + id, user_id: userId === 'me' ? user1.id : userId, team_id: id, roles: ''})),
getChannel: jest.fn((_channelId: string) => ({...channel, id: _channelId})),
getChannelMember: jest.fn((_channelId: string, userId: string) => ({id: userId + '-' + _channelId, user_id: userId === 'me' ? user1.id : userId, channel_id: _channelId, roles: ''})),
sendTestNotification: jest.fn(),
};
beforeAll(() => {
// eslint-disable-next-line
// @ts-ignore
NetworkManager.getClient = () => mockClient;
NetworkManager.getClient = (url) => {
if (serverUrl === url) {
return mockClient;
}
throw new Error('invalid url');
};
});
beforeEach(async () => {
@ -187,3 +194,35 @@ describe('notifications', () => {
expect(result.error).toBeUndefined();
});
});
describe('sendTestNotification', () => {
it('calls client function and returns correctly', async () => {
mockClient.sendTestNotification.mockResolvedValueOnce({status: 'OK'});
const result = await sendTestNotification(serverUrl);
expect(result.status).toBe('OK');
expect(result.error).toBeUndefined();
expect(mockClient.sendTestNotification).toHaveBeenCalled();
});
it('calls client function and returns correctly when error value', async () => {
mockClient.sendTestNotification.mockRejectedValueOnce(new Error('some error'));
const result = await sendTestNotification(serverUrl);
expect(result.error).toBeTruthy();
expect(mockClient.sendTestNotification).toHaveBeenCalled();
});
it('calls client function and returns error on throw', async () => {
mockClient.sendTestNotification.mockImplementationOnce(() => {
throw new Error('error');
});
const result = await sendTestNotification(serverUrl);
expect(result.error).toBeTruthy();
expect(mockClient.sendTestNotification).toHaveBeenCalled();
});
it('show error when wrong server url is used', async () => {
const result = await sendTestNotification('bad server url');
expect(result.error).toBeTruthy();
expect(mockClient.sendTestNotification).not.toHaveBeenCalled();
});
});

View file

@ -13,6 +13,7 @@ import {fetchMyTeam} from '@actions/remote/team';
import {fetchAndSwitchToThread} from '@actions/remote/thread';
import {ActionType} from '@constants';
import DatabaseManager from '@database/manager';
import NetworkManager from '@managers/network_manager';
import {getMyChannel, getChannelById} from '@queries/servers/channel';
import {getCurrentTeamId} from '@queries/servers/system';
import {getMyTeamById, prepareMyTeams} from '@queries/servers/team';
@ -238,3 +239,14 @@ export const openNotification = async (serverUrl: string, notification: Notifica
return {error};
}
};
export const sendTestNotification = async (serverUrl: string): Promise<{status?: 'OK'; error?: unknown}> => {
try {
const client = NetworkManager.getClient(serverUrl);
const result = await client.sendTestNotification();
return result;
} catch (error) {
forceLogoutIfNecessary(serverUrl, error);
return {error};
}
};

View file

@ -0,0 +1,18 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Client} from '.';
import type {APIClientInterface} from '@mattermost/react-native-network-client';
const mockAPIClient = {
post: jest.fn(),
} as any as APIClientInterface;
describe('sendTestNotification', () => {
it('fetch the correct url with the correct method', () => {
const client = new Client(mockAPIClient, 'serverUrl');
client.sendTestNotification();
expect(mockAPIClient.post).toHaveBeenCalledWith('/api/v4/notifications/test', expect.anything());
});
});

View file

@ -33,6 +33,7 @@ export interface ClientPostsMix {
doPostActionWithCookie: (postId: string, actionId: string, actionCookie: string, selectedOption?: string) => Promise<any>;
acknowledgePost: (postId: string, userId: string) => Promise<PostAcknowledgement>;
unacknowledgePost: (postId: string, userId: string) => Promise<any>;
sendTestNotification: () => Promise<{status: 'OK'}>;
}
const ClientPosts = <TBase extends Constructor<ClientBase>>(superclass: TBase) => class extends superclass {
@ -220,6 +221,13 @@ const ClientPosts = <TBase extends Constructor<ClientBase>>(superclass: TBase) =
{method: 'delete'},
);
};
sendTestNotification = async () => {
return this.doFetch(
`${this.urlVersion}/notifications/test`,
{method: 'post'},
);
};
};
export default ClientPosts;

View file

@ -0,0 +1,520 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Section notice match snapshot 1`] = `
<View
style={
[
{
"borderRadius": 4,
"borderStyle": "solid",
"borderWidth": 1,
},
{
"backgroundColor": "rgba(93,137,234,0.08)",
"borderColor": "rgba(93,137,234,0.16)",
},
]
}
testID="sectionNoticeContainer"
>
<View
style={
{
"alignItems": "flex-start",
"flexDirection": "row",
"gap": 12,
"padding": 16,
}
}
>
<Icon
name="information-outline"
size={20}
style={
{
"color": "#5d89ea",
}
}
testID="sectionNoticeHeaderIcon"
/>
<View
style={
{
"flex": 1,
"flexDirection": "column",
"gap": 8,
}
}
>
<Text
style={
{
"color": "#3f4350",
"fontFamily": "OpenSans-SemiBold",
"fontSize": 16,
"fontWeight": "600",
"lineHeight": 24,
"margin": 0,
}
}
>
Some title
</Text>
<View
style={
[
{
"alignItems": "flex-start",
"flexDirection": "row",
"flexWrap": "wrap",
},
]
}
testID="markdown_paragraph"
>
<Text>
<Text
selectable={false}
style={
{
"color": "#3f4350",
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
}
}
testID="markdown_text"
>
Some text
</Text>
</Text>
</View>
<View
style={
{
"gap": 8,
"marginTop": 8,
}
}
>
<View
style={
[
{
"overflow": "hidden",
},
{
"borderRadius": 2,
},
undefined,
false,
]
}
testID="RNE_BUTTON_WRAPPER"
>
<View
accessibilityRole="button"
accessibilityState={
{
"busy": false,
"checked": undefined,
"disabled": false,
"expanded": undefined,
"selected": undefined,
}
}
accessibilityValue={
{
"max": undefined,
"min": undefined,
"now": undefined,
"text": undefined,
}
}
accessible={true}
collapsable={false}
focusable={true}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
style={
{
"opacity": 1,
}
}
>
<View
style={
{
"alignItems": "center",
"backgroundColor": "#1c58d9",
"borderColor": "#2089dc",
"borderRadius": 4,
"borderWidth": 0,
"flex": 0,
"flexDirection": "row",
"height": 40,
"justifyContent": "center",
"padding": 8,
"paddingHorizontal": 20,
"paddingVertical": 12,
}
}
>
<View
style={
[
{
"flexDirection": "row",
},
{
"minHeight": 18,
},
]
}
>
<Icon
color="#ffffff"
name="chevron-right"
size={18}
style={
{
"marginRight": 7,
}
}
/>
<Text
numberOfLines={1}
style={
[
[
{
"alignItems": "center",
"fontFamily": "OpenSans-SemiBold",
"fontWeight": "600",
"justifyContent": "center",
"padding": 1,
"textAlignVertical": "center",
},
{
"fontSize": 14,
"lineHeight": 14,
"marginTop": 3,
},
{
"color": "#ffffff",
},
],
undefined,
]
}
>
primary button
</Text>
</View>
</View>
</View>
</View>
<View
style={
[
{
"overflow": "hidden",
},
{
"borderRadius": 2,
},
undefined,
false,
]
}
testID="RNE_BUTTON_WRAPPER"
>
<View
accessibilityRole="button"
accessibilityState={
{
"busy": false,
"checked": undefined,
"disabled": false,
"expanded": undefined,
"selected": undefined,
}
}
accessibilityValue={
{
"max": undefined,
"min": undefined,
"now": undefined,
"text": undefined,
}
}
accessible={true}
collapsable={false}
focusable={true}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
style={
{
"opacity": 1,
}
}
>
<View
style={
{
"alignItems": "center",
"backgroundColor": "rgba(28,88,217,0.08)",
"borderColor": "#2089dc",
"borderRadius": 4,
"borderWidth": 0,
"flex": 0,
"flexDirection": "row",
"height": 40,
"justifyContent": "center",
"padding": 8,
"paddingHorizontal": 20,
"paddingVertical": 12,
}
}
>
<View
style={
[
{
"flexDirection": "row",
},
{
"minHeight": 18,
},
]
}
>
<Icon
color="#1c58d9"
name="chevron-right"
size={18}
style={
{
"marginRight": 7,
}
}
/>
<Text
numberOfLines={1}
style={
[
[
{
"alignItems": "center",
"fontFamily": "OpenSans-SemiBold",
"fontWeight": "600",
"justifyContent": "center",
"padding": 1,
"textAlignVertical": "center",
},
{
"fontSize": 14,
"lineHeight": 14,
"marginTop": 3,
},
{
"color": "#1c58d9",
},
],
undefined,
]
}
>
secondary button
</Text>
</View>
</View>
</View>
</View>
<View
style={
[
{
"overflow": "hidden",
},
{
"borderRadius": 2,
},
undefined,
false,
]
}
testID="RNE_BUTTON_WRAPPER"
>
<View
accessibilityRole="button"
accessibilityState={
{
"busy": false,
"checked": undefined,
"disabled": false,
"expanded": undefined,
"selected": undefined,
}
}
accessibilityValue={
{
"max": undefined,
"min": undefined,
"now": undefined,
"text": undefined,
}
}
accessible={true}
collapsable={false}
focusable={true}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
style={
{
"opacity": 1,
}
}
>
<View
style={
{
"alignItems": "center",
"backgroundColor": "transparent",
"borderColor": "#2089dc",
"borderRadius": 4,
"borderWidth": 0,
"flex": 0,
"flexDirection": "row",
"height": 40,
"justifyContent": "center",
"padding": 8,
"paddingHorizontal": 20,
"paddingVertical": 12,
}
}
>
<View
style={
[
{
"flexDirection": "row",
},
{
"minHeight": 18,
},
]
}
>
<Icon
color="#1c58d9"
name="chevron-right"
size={18}
style={
{
"marginRight": 7,
}
}
/>
<Text
numberOfLines={1}
style={
[
[
{
"alignItems": "center",
"fontFamily": "OpenSans-SemiBold",
"fontWeight": "600",
"justifyContent": "center",
"padding": 1,
"textAlignVertical": "center",
},
{
"fontSize": 14,
"lineHeight": 14,
"marginTop": 3,
},
{
"color": "#1c58d9",
},
],
undefined,
]
}
>
link button
</Text>
</View>
</View>
</View>
</View>
</View>
</View>
</View>
<View
accessibilityState={
{
"busy": undefined,
"checked": undefined,
"disabled": undefined,
"expanded": undefined,
"selected": undefined,
}
}
accessibilityValue={
{
"max": undefined,
"min": undefined,
"now": undefined,
"text": undefined,
}
}
accessible={true}
collapsable={false}
focusable={true}
onBlur={[Function]}
onClick={[Function]}
onFocus={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
role="button"
style={
{
"alignItems": "center",
"height": 32,
"justifyContent": "center",
"position": "absolute",
"right": 10,
"top": 10,
"width": 32,
}
}
testID="sectionNoticeDismissButton"
>
<Icon
name="close"
size={18}
/>
</View>
</View>
`;

View file

@ -0,0 +1,211 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {type ComponentProps} from 'react';
import {fireEvent, renderWithEverything} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
import SectionNotice from '.';
import type Database from '@nozbe/watermelondb/Database';
function getBaseProps(): ComponentProps<typeof SectionNotice> {
return {
title: 'Some title',
text: 'Some text',
type: 'info',
isDismissable: true,
primaryButton: {
onClick: jest.fn(),
text: 'primary button',
leadingIcon: 'chevron-left',
trailingIcon: 'chevron-right',
loading: true,
},
secondaryButton: {
onClick: jest.fn(),
text: 'secondary button',
leadingIcon: 'chevron-left',
trailingIcon: 'chevron-right',
loading: true,
},
linkButton: {
onClick: jest.fn(),
text: 'link button',
leadingIcon: 'chevron-left',
trailingIcon: 'chevron-right',
loading: true,
},
onDismissClick: jest.fn(),
};
}
describe('Section notice', () => {
let database: Database;
beforeAll(async () => {
const server = await TestHelper.setupServerDatabase();
database = server.database;
});
it('match snapshot', () => {
const props = getBaseProps();
const wrapper = renderWithEverything(<SectionNotice {...props}/>, {database});
expect(wrapper.toJSON()).toMatchSnapshot();
});
it('should show buttons only if defined', () => {
const props = getBaseProps();
const wrapper = renderWithEverything(<SectionNotice {...props}/>, {database});
const primaryText = props.primaryButton!.text;
const secondaryText = props.secondaryButton!.text;
const linkText = props.linkButton!.text;
expect(wrapper.getAllByRole('button')).toHaveLength(4);
expect(wrapper.getByText(primaryText)).toBeVisible();
expect(wrapper.getByText(secondaryText)).toBeVisible();
expect(wrapper.getByText(linkText)).toBeVisible();
expect(wrapper.getByTestId('sectionNoticeDismissButton')).toBeVisible();
props.primaryButton = undefined;
wrapper.rerender(<SectionNotice {...props}/>);
expect(wrapper.getAllByRole('button')).toHaveLength(3);
expect(wrapper.queryByText(primaryText)).not.toBeVisible();
expect(wrapper.getByText(secondaryText)).toBeVisible();
expect(wrapper.getByText(linkText)).toBeVisible();
expect(wrapper.getByTestId('sectionNoticeDismissButton')).toBeVisible();
props.secondaryButton = undefined;
wrapper.rerender(<SectionNotice {...props}/>);
expect(wrapper.getAllByRole('button')).toHaveLength(2);
expect(wrapper.queryByText(primaryText)).not.toBeVisible();
expect(wrapper.queryByText(secondaryText)).not.toBeVisible();
expect(wrapper.getByText(linkText)).toBeVisible();
expect(wrapper.getByTestId('sectionNoticeDismissButton')).toBeVisible();
props.linkButton = undefined;
wrapper.rerender(<SectionNotice {...props}/>);
expect(wrapper.getAllByRole('button')).toHaveLength(1);
expect(wrapper.queryByText(primaryText)).not.toBeVisible();
expect(wrapper.queryByText(secondaryText)).not.toBeVisible();
expect(wrapper.queryByText(linkText)).not.toBeVisible();
expect(wrapper.getByTestId('sectionNoticeDismissButton')).toBeVisible();
props.isDismissable = false;
wrapper.rerender(<SectionNotice {...props}/>);
expect(wrapper.queryAllByRole('button')).toHaveLength(0);
expect(wrapper.queryByText(primaryText)).not.toBeVisible();
expect(wrapper.queryByText(secondaryText)).not.toBeVisible();
expect(wrapper.queryByText(linkText)).not.toBeVisible();
expect(wrapper.queryByTestId('sectionNoticeDismissButton')).not.toBeVisible();
});
it('should show the correct icon on each section type', () => {
const props = getBaseProps();
props.type = 'info';
const wrapper = renderWithEverything(<SectionNotice {...props}/>, {database});
let icon = wrapper.getByTestId('sectionNoticeHeaderIcon');
expect(icon).toBeVisible();
expect(icon.props).toHaveProperty('name', 'information-outline');
props.type = 'danger';
wrapper.rerender(<SectionNotice {...props}/>);
icon = wrapper.getByTestId('sectionNoticeHeaderIcon');
expect(icon).toBeVisible();
expect(icon.props).toHaveProperty('name', 'alert-outline');
props.type = 'hint';
wrapper.rerender(<SectionNotice {...props}/>);
icon = wrapper.getByTestId('sectionNoticeHeaderIcon');
expect(icon).toBeVisible();
expect(icon.props).toHaveProperty('name', 'lightbulb-outline');
props.type = 'success';
wrapper.rerender(<SectionNotice {...props}/>);
icon = wrapper.getByTestId('sectionNoticeHeaderIcon');
expect(icon).toBeVisible();
expect(icon.props).toHaveProperty('name', 'check');
props.type = 'warning';
wrapper.rerender(<SectionNotice {...props}/>);
icon = wrapper.getByTestId('sectionNoticeHeaderIcon');
expect(icon).toBeVisible();
expect(icon.props).toHaveProperty('name', 'alert-outline');
props.type = 'welcome';
wrapper.rerender(<SectionNotice {...props}/>);
expect(wrapper.queryByTestId('sectionNoticeHeaderIcon')).not.toBeVisible();
});
it('should have the correct background on each section type', () => {
const props = getBaseProps();
props.type = 'info';
const wrapper = renderWithEverything(<SectionNotice {...props}/>, {database});
let container = wrapper.getByTestId('sectionNoticeContainer');
expect(container).toHaveStyle({backgroundColor: 'rgba(93,137,234,0.08)'});
props.type = 'danger';
wrapper.rerender(<SectionNotice {...props}/>);
container = wrapper.getByTestId('sectionNoticeContainer');
expect(container).toHaveStyle({backgroundColor: 'rgba(210,75,78,0.08)'});
props.type = 'hint';
wrapper.rerender(<SectionNotice {...props}/>);
container = wrapper.getByTestId('sectionNoticeContainer');
expect(container).toHaveStyle({backgroundColor: 'rgba(93,137,234,0.08)'});
props.type = 'success';
wrapper.rerender(<SectionNotice {...props}/>);
container = wrapper.getByTestId('sectionNoticeContainer');
expect(container).toHaveStyle({backgroundColor: 'rgba(61,184,135,0.08)'});
props.type = 'warning';
wrapper.rerender(<SectionNotice {...props}/>);
container = wrapper.getByTestId('sectionNoticeContainer');
expect(container).toHaveStyle({backgroundColor: 'rgba(255,188,31,0.08)'});
props.type = 'welcome';
wrapper.rerender(<SectionNotice {...props}/>);
container = wrapper.getByTestId('sectionNoticeContainer');
expect(container).toHaveStyle({backgroundColor: 'rgba(63,67,80,0.04)'});
});
it('all buttons perform the expected action', () => {
const props = getBaseProps();
const wrapper = renderWithEverything(<SectionNotice {...props}/>, {database});
fireEvent.press(wrapper.getByText(props.primaryButton!.text));
expect(props.primaryButton!.onClick).toHaveBeenCalled();
expect(props.secondaryButton!.onClick).not.toHaveBeenCalled();
expect(props.linkButton!.onClick).not.toHaveBeenCalled();
expect(props.onDismissClick!).not.toHaveBeenCalled();
jest.clearAllMocks();
fireEvent.press(wrapper.getByText(props.secondaryButton!.text));
expect(props.primaryButton!.onClick).not.toHaveBeenCalled();
expect(props.secondaryButton!.onClick).toHaveBeenCalled();
expect(props.linkButton!.onClick).not.toHaveBeenCalled();
expect(props.onDismissClick!).not.toHaveBeenCalled();
jest.clearAllMocks();
fireEvent.press(wrapper.getByText(props.linkButton!.text));
expect(props.primaryButton!.onClick).not.toHaveBeenCalled();
expect(props.secondaryButton!.onClick).not.toHaveBeenCalled();
expect(props.linkButton!.onClick).toHaveBeenCalled();
expect(props.onDismissClick!).not.toHaveBeenCalled();
jest.clearAllMocks();
fireEvent.press(wrapper.getByTestId('sectionNoticeDismissButton'));
expect(props.primaryButton!.onClick).not.toHaveBeenCalled();
expect(props.secondaryButton!.onClick).not.toHaveBeenCalled();
expect(props.linkButton!.onClick).not.toHaveBeenCalled();
expect(props.onDismissClick!).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,228 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useMemo} from 'react';
import {Pressable, Text, View} from 'react-native';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import CompassIcon from '../compass_icon';
import Markdown from '../markdown';
import SectionNoticeButton from './section_notice_button';
type Props = {
title: string;
text?: string;
primaryButton?: SectionNoticeButtonProps;
secondaryButton?: SectionNoticeButtonProps;
linkButton?: SectionNoticeButtonProps;
type?: 'info' | 'success' | 'danger' | 'welcome' | 'warning' | 'hint';
isDismissable?: boolean;
onDismissClick?: () => void;
}
const iconByType = {
info: 'information-outline',
hint: 'lightbulb-outline',
success: 'check',
danger: 'alert-outline',
warning: 'alert-outline',
welcome: undefined,
};
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
return {
container: {
borderWidth: 1,
borderStyle: 'solid',
borderRadius: 4,
},
content: {
flexDirection: 'row',
alignItems: 'flex-start',
padding: 16,
gap: 12,
},
body: {
flexDirection: 'column',
gap: 8,
flex: 1,
},
actions: {
marginTop: 8,
gap: 8,
},
title: {
margin: 0,
color: theme.centerChannelColor,
...typography('Body', 200, 'SemiBold'),
},
welcomeTitle: {
margin: 0,
color: theme.centerChannelColor,
...typography('Heading', 400, 'SemiBold'),
},
baseText: {
color: theme.centerChannelColor,
...typography('Body', 200, 'Regular'),
},
infoText: {
color: theme.centerChannelColor,
},
infoIcon: {
color: theme.sidebarTextActiveBorder,
},
infoContainer: {
borderColor: changeOpacity(theme.sidebarTextActiveBorder, 0.16),
backgroundColor: changeOpacity(theme.sidebarTextActiveBorder, 0.08),
},
successText: {
color: theme.centerChannelColor,
},
successIcon: {
color: theme.onlineIndicator,
},
successContainer: {
borderColor: changeOpacity(theme.onlineIndicator, 0.16),
backgroundColor: changeOpacity(theme.onlineIndicator, 0.08),
},
dangerText: {
color: theme.dndIndicator,
},
dangerIcon: {
color: theme.sidebarTextActiveBorder,
},
dangerContainer: {
borderColor: changeOpacity(theme.dndIndicator, 0.16),
backgroundColor: changeOpacity(theme.dndIndicator, 0.08),
},
warningText: {
color: theme.awayIndicator,
},
warningIcon: {
color: theme.sidebarTextActiveBorder,
},
warningContainer: {
borderColor: changeOpacity(theme.awayIndicator, 0.16),
backgroundColor: changeOpacity(theme.awayIndicator, 0.08),
},
welcomeText: {
color: theme.centerChannelColor,
},
welcomeIcon: {
color: theme.centerChannelColor,
},
welcomeContainer: {
borderColor: changeOpacity(theme.centerChannelColor, 0.08),
backgroundColor: changeOpacity(theme.centerChannelColor, 0.04),
},
hintText: {
color: theme.centerChannelColor,
},
hintIcon: {
color: theme.sidebarTextActiveBorder,
},
hintContainer: {
borderColor: changeOpacity(theme.sidebarTextActiveBorder, 0.16),
backgroundColor: changeOpacity(theme.sidebarTextActiveBorder, 0.08),
},
dismissIcon: {
position: 'absolute',
alignItems: 'center',
justifyContent: 'center',
right: 10,
top: 10,
width: 32,
height: 32,
},
};
});
const SectionNotice = ({
title,
isDismissable,
linkButton,
onDismissClick,
primaryButton,
secondaryButton,
text,
type = 'info',
}: Props) => {
const theme = useTheme();
const styles = getStyleFromTheme(theme);
const icon = iconByType[type];
const showDismiss = Boolean(isDismissable && onDismissClick);
const hasButtons = primaryButton || secondaryButton || linkButton;
const containerStyle = useMemo(() => [styles.container, styles[`${type}Container`]], [type]);
const iconStyle = useMemo(() => styles[`${type}Icon`], [type]);
return (
<View
style={containerStyle}
testID={'sectionNoticeContainer'}
>
<View style={styles.content}>
{icon && (
<CompassIcon
name={icon}
style={iconStyle}
size={20}
testID='sectionNoticeHeaderIcon'
/>
)}
<View style={styles.body}>
<Text style={styles.title}>{title}</Text>
{text && (
<Markdown
theme={theme}
location=''
baseTextStyle={styles.baseText}
value={text}
/>
)}
{hasButtons && (
<View style={styles.actions}>
{primaryButton && (
<SectionNoticeButton
button={primaryButton}
emphasis='primary'
/>
)}
{secondaryButton && (
<SectionNoticeButton
button={secondaryButton}
emphasis='tertiary'
/>
)}
{linkButton && (
<SectionNoticeButton
button={linkButton}
emphasis='link'
/>
)}
</View>
)}
</View>
</View>
{showDismiss && (
<Pressable
style={styles.dismissIcon}
role={'button'}
onPress={onDismissClick}
testID={'sectionNoticeDismissButton'}
>
<CompassIcon
name={'close'}
size={18}
/>
</Pressable>
)}
</View>
);
};
export default SectionNotice;

View file

@ -0,0 +1,35 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useTheme} from '@context/theme';
import Button from '../button';
type ButtonProps = {
button: SectionNoticeButtonProps;
emphasis: 'primary' | 'tertiary' | 'link';
}
const SectionNoticeButton = ({
button,
emphasis,
}: ButtonProps) => {
const theme = useTheme();
const leadingIcon = button.leadingIcon ? {iconName: button.leadingIcon, iconSize: 18} : {};
const trailingIcon = button.trailingIcon ? {iconName: button.trailingIcon, iconSize: 18} : {};
return (
<Button
onPress={button.onClick}
text={button.text}
theme={theme}
emphasis={emphasis}
{...leadingIcon}
{...trailingIcon}
/>
);
};
export default SectionNoticeButton;

View file

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

View file

@ -8,7 +8,7 @@ import {type Edge, SafeAreaView} from 'react-native-safe-area-context';
import {useTheme} from '@context/theme';
import {makeStyleSheetFromTheme} from '@utils/theme';
const edges: Edge[] = ['left', 'right'];
const edges: Edge[] = ['left', 'right', 'bottom'];
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return {
@ -18,6 +18,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
},
contentContainerStyle: {
marginTop: 8,
flexGrow: 1,
},
};
});

View file

@ -0,0 +1,119 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {renderHook} from '@testing-library/react-native';
import {URL} from 'react-native-url-polyfill';
import {useExternalLink} from './use_external_link';
const baseCurrentUserId = 'someUserId';
const baseTelemetryId = 'someTelemetryId';
function getBaseProps(): Parameters<typeof useExternalLink>[0] {
return {
userId: baseCurrentUserId,
isCloud: true,
telemetryId: baseTelemetryId,
};
}
describe('useExternalLink', () => {
it('keep non mattermost links untouched', () => {
const url = 'https://www.someLink.com/something?query1=2#anchor';
const {result: {current: [href, queryParams]}} = renderHook(() => useExternalLink(getBaseProps(), url, 'some location', {utm_source: 'something'}));
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]}} = renderHook(() => useExternalLink(getBaseProps(), url));
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]}} = renderHook(() => useExternalLink(getBaseProps(), url, location));
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 stateProps = getBaseProps();
stateProps.isCloud = false;
const {result: {current: [href, queryParams]}} = renderHook(() => useExternalLink(stateProps, url));
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]}} = renderHook(() => useExternalLink(getBaseProps(), url));
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]}} = renderHook(() => useExternalLink(getBaseProps(), url));
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]}} = renderHook(() => useExternalLink(getBaseProps(), url, location, {utm_content: expectedContent, utm_source: expectedSource}));
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]}} = renderHook(() => useExternalLink(getBaseProps(), url, location, {utm_content: overwrittenContent, utm_source: overwrittenSource}));
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} = renderHook(() => useExternalLink(getBaseProps(), url, 'someLocation', overwriteQueryParams));
const [firstHref, firstParams] = result.current;
rerender(undefined);
const [secondHref, secondParams] = result.current;
expect(firstHref).toBe(secondHref);
expect(firstParams).toBe(secondParams);
});
});

View file

@ -0,0 +1,54 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useMemo} from 'react';
import {URL, URLSearchParams} from 'react-native-url-polyfill';
export type ExternalLinkQueryParams = {
utm_source?: string;
utm_medium?: string;
utm_campaign?: string;
utm_content?: string;
userId?: string;
}
type StateProps = {
userId: string;
telemetryId: string;
isCloud: boolean;
}
// This mimics the behavior of webapp/channels/src/components/common/hooks/use_external_link.ts
export function useExternalLink(
{
userId,
telemetryId,
isCloud,
}: StateProps,
href: string,
location: string = '',
overwriteQueryParams: ExternalLinkQueryParams = {},
): [string, Record<string, string>] {
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

@ -8,7 +8,7 @@ import {switchMap} from 'rxjs/operators';
import {Preferences} from '@constants';
import {getPreferenceValue} from '@helpers/api/preference';
import {queryPreferencesByCategoryAndName} from '@queries/servers/preference';
import {observeConfigBooleanValue} from '@queries/servers/system';
import {observeConfigBooleanValue, observeConfigValue} from '@queries/servers/system';
import {observeIsCRTEnabled} from '@queries/servers/thread';
import {observeCurrentUser} from '@queries/servers/user';
@ -27,6 +27,7 @@ const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
switchMap((preferences) => of$(getPreferenceValue<string>(preferences, Preferences.CATEGORIES.NOTIFICATIONS, Preferences.EMAIL_INTERVAL, Preferences.INTERVAL_NOT_SET))),
),
sendEmailNotifications: observeConfigBooleanValue(database, 'SendEmailNotifications'),
serverVersion: observeConfigValue(database, 'Version'),
};
});

View file

@ -15,6 +15,8 @@ import {popTopScreen} from '@screens/navigation';
import {gotoSettingsScreen} from '@screens/settings/config';
import {getEmailInterval, getEmailIntervalTexts, getNotificationProps} from '@utils/user';
import SendTestNotificationNotice from './send_test_notification_notice';
import type UserModel from '@typings/database/models/servers/user';
import type {AvailableScreens} from '@typings/screens/navigation';
@ -45,6 +47,7 @@ type NotificationsProps = {
enableEmailBatching: boolean;
isCRTEnabled: boolean;
sendEmailNotifications: boolean;
serverVersion: string;
}
const Notifications = ({
componentId,
@ -54,6 +57,7 @@ const Notifications = ({
enableEmailBatching,
isCRTEnabled,
sendEmailNotifications,
serverVersion,
}: NotificationsProps) => {
const intl = useIntl();
const serverUrl = useServerUrl();
@ -75,7 +79,7 @@ const Notifications = ({
const defaultMessage = isCRTEnabled ? 'Mentions' : 'Mentions and Replies';
const title = intl.formatMessage({id, defaultMessage});
gotoSettingsScreen(screen, title);
}, [isCRTEnabled]);
}, [intl, isCRTEnabled]);
const goToNotificationSettingsPush = useCallback(() => {
const screen = Screens.SETTINGS_NOTIFICATION_PUSH;
@ -85,7 +89,7 @@ const Notifications = ({
});
gotoSettingsScreen(screen, title);
}, []);
}, [intl]);
const callsNotificationsOn = useMemo(() => Boolean(notifyProps?.calls_mobile_sound ? notifyProps.calls_mobile_sound === 'true' : notifyProps?.calls_desktop_sound === 'true'),
[notifyProps]);
@ -97,7 +101,7 @@ const Notifications = ({
});
gotoSettingsScreen(screen, title);
}, []);
}, [intl]);
const goToNotificationAutoResponder = useCallback(() => {
const screen = Screens.SETTINGS_NOTIFICATION_AUTO_RESPONDER;
@ -106,13 +110,13 @@ const Notifications = ({
defaultMessage: 'Automatic Replies',
});
gotoSettingsScreen(screen, title);
}, []);
}, [intl]);
const goToEmailSettings = useCallback(() => {
const screen = Screens.SETTINGS_NOTIFICATION_EMAIL;
const title = intl.formatMessage({id: 'notification_settings.email', defaultMessage: 'Email Notifications'});
gotoSettingsScreen(screen, title);
}, []);
}, [intl]);
const close = useCallback(() => {
popTopScreen(componentId);
@ -161,6 +165,10 @@ const Notifications = ({
testID='notification_settings.automatic_replies.option'
/>
)}
<SendTestNotificationNotice
serverVersion={serverVersion}
userId={currentUser?.id || ''}
/>
</SettingContainer>
);
};

View file

@ -0,0 +1,344 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SendTestNotificationNotice should match snapshot 1`] = `
<View
style={
{
"flex": 1,
"justifyContent": "flex-end",
"margin": 16,
}
}
>
<View
style={
[
{
"borderRadius": 4,
"borderStyle": "solid",
"borderWidth": 1,
},
{
"backgroundColor": "rgba(93,137,234,0.08)",
"borderColor": "rgba(93,137,234,0.16)",
},
]
}
testID="sectionNoticeContainer"
>
<View
style={
{
"alignItems": "flex-start",
"flexDirection": "row",
"gap": 12,
"padding": 16,
}
}
>
<Icon
name="lightbulb-outline"
size={20}
style={
{
"color": "#5d89ea",
}
}
testID="sectionNoticeHeaderIcon"
/>
<View
style={
{
"flex": 1,
"flexDirection": "column",
"gap": 8,
}
}
>
<Text
style={
{
"color": "#3f4350",
"fontFamily": "OpenSans-SemiBold",
"fontSize": 16,
"fontWeight": "600",
"lineHeight": 24,
"margin": 0,
}
}
>
Troubleshooting notifications
</Text>
<View
style={
[
{
"alignItems": "flex-start",
"flexDirection": "row",
"flexWrap": "wrap",
},
]
}
testID="markdown_paragraph"
>
<Text>
<Text
selectable={false}
style={
{
"color": "#3f4350",
"fontFamily": "OpenSans",
"fontSize": 16,
"fontWeight": "400",
"lineHeight": 24,
}
}
testID="markdown_text"
>
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.
</Text>
</Text>
</View>
<View
style={
{
"gap": 8,
"marginTop": 8,
}
}
>
<View
style={
[
{
"overflow": "hidden",
},
{
"borderRadius": 2,
},
undefined,
false,
]
}
testID="RNE_BUTTON_WRAPPER"
>
<View
accessibilityRole="button"
accessibilityState={
{
"busy": false,
"checked": undefined,
"disabled": false,
"expanded": undefined,
"selected": undefined,
}
}
accessibilityValue={
{
"max": undefined,
"min": undefined,
"now": undefined,
"text": undefined,
}
}
accessible={true}
collapsable={false}
focusable={true}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
style={
{
"opacity": 1,
}
}
>
<View
style={
{
"alignItems": "center",
"backgroundColor": "#1c58d9",
"borderColor": "#2089dc",
"borderRadius": 4,
"borderWidth": 0,
"flex": 0,
"flexDirection": "row",
"height": 40,
"justifyContent": "center",
"padding": 8,
"paddingHorizontal": 20,
"paddingVertical": 12,
}
}
>
<View
style={
{
"flexDirection": "row",
}
}
>
<Text
numberOfLines={1}
style={
[
[
{
"alignItems": "center",
"fontFamily": "OpenSans-SemiBold",
"fontWeight": "600",
"justifyContent": "center",
"padding": 1,
"textAlignVertical": "center",
},
{
"fontSize": 14,
"lineHeight": 14,
"marginTop": 3,
},
{
"color": "#ffffff",
},
],
undefined,
]
}
>
Send a test notification
</Text>
</View>
</View>
</View>
</View>
<View
style={
[
{
"overflow": "hidden",
},
{
"borderRadius": 2,
},
undefined,
false,
]
}
testID="RNE_BUTTON_WRAPPER"
>
<View
accessibilityRole="button"
accessibilityState={
{
"busy": false,
"checked": undefined,
"disabled": false,
"expanded": undefined,
"selected": undefined,
}
}
accessibilityValue={
{
"max": undefined,
"min": undefined,
"now": undefined,
"text": undefined,
}
}
accessible={true}
collapsable={false}
focusable={true}
onClick={[Function]}
onResponderGrant={[Function]}
onResponderMove={[Function]}
onResponderRelease={[Function]}
onResponderTerminate={[Function]}
onResponderTerminationRequest={[Function]}
onStartShouldSetResponder={[Function]}
style={
{
"opacity": 1,
}
}
>
<View
style={
{
"alignItems": "center",
"backgroundColor": "rgba(28,88,217,0.08)",
"borderColor": "#2089dc",
"borderRadius": 4,
"borderWidth": 0,
"flex": 0,
"flexDirection": "row",
"height": 40,
"justifyContent": "center",
"padding": 8,
"paddingHorizontal": 20,
"paddingVertical": 12,
}
}
>
<View
style={
[
{
"flexDirection": "row",
},
{
"minHeight": 18,
},
]
}
>
<Icon
color="#1c58d9"
name="open-in-new"
size={18}
style={
{
"marginRight": 7,
}
}
/>
<Text
numberOfLines={1}
style={
[
[
{
"alignItems": "center",
"fontFamily": "OpenSans-SemiBold",
"fontWeight": "600",
"justifyContent": "center",
"padding": 1,
"textAlignVertical": "center",
},
{
"fontSize": 14,
"lineHeight": 14,
"marginTop": 3,
},
{
"color": "#1c58d9",
},
],
undefined,
]
}
>
Troubleshooting docs
</Text>
</View>
</View>
</View>
</View>
</View>
</View>
</View>
</View>
</View>
`;

View file

@ -0,0 +1,21 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {observeConfigValue, observeLicense} from '@queries/servers/system';
import SendTestNotificationNotice from './send_test_notification_notice';
import type {WithDatabaseArgs} from '@typings/database/database';
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
return {
isCloud: observeLicense(database).pipe(switchMap((v) => of$(v?.Cloud === 'true'))),
telemetryId: observeConfigValue(database, 'TelemetryId').pipe(switchMap((v) => of$(v || ''))),
};
});
export default withDatabase(enhanced(SendTestNotificationNotice));

View file

@ -0,0 +1,138 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {sendTestNotification} from '@actions/remote/notifications';
import {act, fireEvent, renderWithEverything} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
import {logError} from '@utils/log';
import {tryOpenURL} from '@utils/url';
import SendTestNotificationNotice from './send_test_notification_notice';
import type Database from '@nozbe/watermelondb/Database';
const version = '10.3.0';
const oldVersion = '10.2.0';
jest.mock('@utils/url', () => {
return {
tryOpenURL: jest.fn(),
};
});
jest.mock('@actions/remote/notifications', () => {
return {
sendTestNotification: jest.fn(),
};
});
jest.mock('@utils/log', () => {
return {
logError: jest.fn(),
};
});
const mockedSendTestNotification = jest.mocked(sendTestNotification);
const allTimerExceptSetTimeout = [
'Date' as const,
'hrtime' as const,
'nextTick' as const,
'performance' as const,
'queueMicrotask' as const,
'requestAnimationFrame' as const,
'cancelAnimationFrame' as const,
'requestIdleCallback' as const,
'cancelIdleCallback' as const,
'setImmediate' as const,
'clearImmediate' as const,
'setInterval' as const,
'clearInterval' as const,
'clearTimeout' as const,
];
function getBaseProps() {
return {
serverVersion: version,
telemetryId: 'someId',
userId: 'someUserId',
isCloud: true,
};
}
describe('SendTestNotificationNotice', () => {
let database: Database;
beforeAll(async () => {
const server = await TestHelper.setupServerDatabase();
database = server.database;
});
it('should match snapshot', () => {
const wrapper = renderWithEverything(<SendTestNotificationNotice {...getBaseProps()}/>, {database});
expect(wrapper.toJSON()).toMatchSnapshot();
});
it('should show nothing on older versions', () => {
const props = getBaseProps();
props.serverVersion = oldVersion;
const wrapper = renderWithEverything(<SendTestNotificationNotice {...props}/>, {database});
expect(wrapper.toJSON()).toBeNull();
});
it('should open the correct url for troubleshooting docs', () => {
const wrapper = renderWithEverything(<SendTestNotificationNotice {...getBaseProps()}/>, {database});
fireEvent.press(wrapper.getByText('Troubleshooting docs'));
expect(tryOpenURL).toHaveBeenCalledWith('https://mattermost.com/pl/troubleshoot-notifications?utm_source=mattermost&utm_medium=in-product-cloud&utm_content=&uid=someUserId&sid=someId');
});
it('should call send notification action when the send notification button is clicked, and all states go through correctly', async () => {
jest.useFakeTimers({doNotFake: allTimerExceptSetTimeout});
const wrapper = renderWithEverything(<SendTestNotificationNotice {...getBaseProps()}/>, {database});
let resolve: ((value: any) => void) | undefined;
mockedSendTestNotification.mockReturnValueOnce(new Promise((r) => {
resolve = r;
}));
fireEvent.press(wrapper.getByText('Send a test notification'));
expect(sendTestNotification).toHaveBeenCalled();
expect(wrapper.getByText('Sending a test notification')).toBeVisible();
await act(() => {
resolve?.({data: true});
});
expect(wrapper.getByText('Test notification sent')).toBeVisible();
act(() => {
jest.advanceTimersByTime(3001);
});
expect(wrapper.getByText('Send a test notification')).toBeVisible();
});
it('should call send notification action when the send notification button is clicked, and when it fails, all states go through correctly', async () => {
jest.useFakeTimers({doNotFake: allTimerExceptSetTimeout});
const wrapper = renderWithEverything(<SendTestNotificationNotice {...getBaseProps()}/>, {database});
let resolve: ((value: any) => void) | undefined;
mockedSendTestNotification.mockReturnValueOnce(new Promise((r) => {
resolve = r;
}));
fireEvent.press(wrapper.getByText('Send a test notification'));
expect(sendTestNotification).toHaveBeenCalled();
expect(wrapper.getByText('Sending a test notification')).toBeVisible();
await act(() => {
resolve?.({error: 'some error'});
});
expect(wrapper.getByText('Error sending test notification')).toBeVisible();
expect(logError).toHaveBeenCalledWith({error: 'some error'});
act(() => {
jest.advanceTimersByTime(3001);
});
expect(wrapper.getByText('Send a test notification')).toBeVisible();
});
});

View file

@ -0,0 +1,139 @@
// 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 {View} from 'react-native';
import {sendTestNotification} from '@actions/remote/notifications';
import SectionNotice from '@components/section_notice';
import {useServerUrl} from '@context/server';
import {useExternalLink} from '@hooks/use_external_link';
import {isMinimumServerVersion} from '@utils/helpers';
import {logError} from '@utils/log';
import {tryOpenURL} from '@utils/url';
const TIME_TO_IDLE = 3000;
type Props = {
serverVersion: string;
userId: string;
isCloud: boolean;
telemetryId: string;
}
type ButtonState = 'idle'|'sending'|'sent'|'error';
const styles = {
wrapper: {
flex: 1,
justifyContent: 'flex-end' as const,
margin: 16,
},
};
const SendTestNotificationNotice = ({
serverVersion,
userId,
isCloud,
telemetryId,
}: Props) => {
const intl = useIntl();
const serverUrl = useServerUrl();
const [buttonState, setButtonState] = useState<ButtonState>('idle');
const isSending = useRef(false);
const timeout = useRef<NodeJS.Timeout>();
const [href] = useExternalLink({
userId,
isCloud,
telemetryId,
}, 'https://mattermost.com/pl/troubleshoot-notifications');
const onGoToNotificationDocumentation = useCallback(() => {
tryOpenURL(href);
}, [href]);
const onSendTestNotificationClick = useCallback(async () => {
if (isSending.current) {
return;
}
isSending.current = true;
setButtonState('sending');
const result = await sendTestNotification(serverUrl);
if (result.error) {
logError(result);
setButtonState('error');
} else {
setButtonState('sent');
}
timeout.current = setTimeout(() => {
isSending.current = false;
setButtonState('idle');
}, TIME_TO_IDLE);
}, [serverUrl]);
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 = 'check';
break;
case 'error':
text = intl.formatMessage({id: 'user_settings.notifications.test_notification.send_button.error', defaultMessage: 'Error sending test notification'});
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: 'open-in-new',
};
}, [intl, onGoToNotificationDocumentation]);
if (!isMinimumServerVersion(serverVersion, 10, 3)) {
return null;
}
return (
<View style={styles.wrapper}>
<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}
secondaryButton={secondaryButton}
type='hint'
/>
</View>
);
};
export default SendTestNotificationNotice;

View file

@ -1163,6 +1163,13 @@
"unsupported_server.message": "Your server, {serverDisplayName}, is running an unsupported server version. You may experience compatibility issues that cause crashes or severe bugs breaking core functionality of the app. Please contact your System Administrator to upgrade your Mattermost server.",
"unsupported_server.title": "Unsupported server version",
"user_profile.custom_status": "Custom Status",
"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_status.away": "Away",
"user_status.dnd": "Do Not Disturb",
"user_status.offline": "Offline",

12
package-lock.json generated
View file

@ -91,6 +91,7 @@
"react-native-shadow-2": "7.1.0",
"react-native-share": "10.2.1",
"react-native-svg": "15.4.0",
"react-native-url-polyfill": "2.0.0",
"react-native-vector-icons": "10.1.0",
"react-native-video": "6.4.3",
"react-native-walkthrough-tooltip": "1.6.0",
@ -23755,6 +23756,17 @@
"react-native": "*"
}
},
"node_modules/react-native-url-polyfill": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/react-native-url-polyfill/-/react-native-url-polyfill-2.0.0.tgz",
"integrity": "sha512-My330Do7/DvKnEvwQc0WdcBnFPploYKp9CYlefDXzIdEaA+PAhDYllkvGeEroEzvc4Kzzj2O4yVdz8v6fjRvhA==",
"dependencies": {
"whatwg-url-without-unicode": "8.0.0-3"
},
"peerDependencies": {
"react-native": "*"
}
},
"node_modules/react-native-vector-icons": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/react-native-vector-icons/-/react-native-vector-icons-10.1.0.tgz",

View file

@ -92,6 +92,7 @@
"react-native-shadow-2": "7.1.0",
"react-native-share": "10.2.1",
"react-native-svg": "15.4.0",
"react-native-url-polyfill": "2.0.0",
"react-native-vector-icons": "10.1.0",
"react-native-video": "6.4.3",
"react-native-walkthrough-tooltip": "1.6.0",

View file

@ -188,6 +188,7 @@ interface ClientConfig {
SiteURL: string;
SupportEmail: string;
TeammateNameDisplay: string;
TelemetryId: string;
TermsOfServiceLink: string;
TimeBetweenUserTypingUpdatesMilliseconds: string;
Version: string;