From e0992c0bf6424f30532f34fe5c309f583b5db6ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Espino=20Garc=C3=ADa?= Date: Mon, 11 Nov 2024 10:22:57 +0100 Subject: [PATCH] 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 --- app/actions/remote/notifications.test.ts | 41 +- app/actions/remote/notifications.ts | 12 + app/client/rest/posts.test.ts | 18 + app/client/rest/posts.ts | 8 + .../__snapshots__/index.test.tsx.snap | 520 ++++++++++++++++++ app/components/section_notice/index.test.tsx | 211 +++++++ app/components/section_notice/index.tsx | 228 ++++++++ .../section_notice/section_notice_button.tsx | 35 ++ app/components/section_notice/types.d.ts | 10 + app/components/settings/container.tsx | 3 +- app/hooks/use_external_link.test.ts | 119 ++++ app/hooks/use_external_link.ts | 54 ++ app/screens/settings/notifications/index.tsx | 3 +- .../settings/notifications/notifications.tsx | 18 +- ...end_test_notification_notice.test.tsx.snap | 344 ++++++++++++ .../send_test_notification_notice/index.ts | 21 + .../send_test_notification_notice.test.tsx | 138 +++++ .../send_test_notification_notice.tsx | 139 +++++ assets/base/i18n/en.json | 7 + package-lock.json | 12 + package.json | 1 + types/api/config.d.ts | 1 + 22 files changed, 1935 insertions(+), 8 deletions(-) create mode 100644 app/client/rest/posts.test.ts create mode 100644 app/components/section_notice/__snapshots__/index.test.tsx.snap create mode 100644 app/components/section_notice/index.test.tsx create mode 100644 app/components/section_notice/index.tsx create mode 100644 app/components/section_notice/section_notice_button.tsx create mode 100644 app/components/section_notice/types.d.ts create mode 100644 app/hooks/use_external_link.test.ts create mode 100644 app/hooks/use_external_link.ts create mode 100644 app/screens/settings/notifications/send_test_notification_notice/__snapshots__/send_test_notification_notice.test.tsx.snap create mode 100644 app/screens/settings/notifications/send_test_notification_notice/index.ts create mode 100644 app/screens/settings/notifications/send_test_notification_notice/send_test_notification_notice.test.tsx create mode 100644 app/screens/settings/notifications/send_test_notification_notice/send_test_notification_notice.tsx diff --git a/app/actions/remote/notifications.test.ts b/app/actions/remote/notifications.test.ts index f4b5c18a5..72b2cdced 100644 --- a/app/actions/remote/notifications.test.ts +++ b/app/actions/remote/notifications.test.ts @@ -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(); + }); +}); diff --git a/app/actions/remote/notifications.ts b/app/actions/remote/notifications.ts index 5f1d039f1..5b2da9092 100644 --- a/app/actions/remote/notifications.ts +++ b/app/actions/remote/notifications.ts @@ -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}; + } +}; diff --git a/app/client/rest/posts.test.ts b/app/client/rest/posts.test.ts new file mode 100644 index 000000000..3fc8b4b99 --- /dev/null +++ b/app/client/rest/posts.test.ts @@ -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()); + }); +}); diff --git a/app/client/rest/posts.ts b/app/client/rest/posts.ts index 3da9abaed..32bd2a965 100644 --- a/app/client/rest/posts.ts +++ b/app/client/rest/posts.ts @@ -33,6 +33,7 @@ export interface ClientPostsMix { doPostActionWithCookie: (postId: string, actionId: string, actionCookie: string, selectedOption?: string) => Promise; acknowledgePost: (postId: string, userId: string) => Promise; unacknowledgePost: (postId: string, userId: string) => Promise; + sendTestNotification: () => Promise<{status: 'OK'}>; } const ClientPosts = >(superclass: TBase) => class extends superclass { @@ -220,6 +221,13 @@ const ClientPosts = >(superclass: TBase) = {method: 'delete'}, ); }; + + sendTestNotification = async () => { + return this.doFetch( + `${this.urlVersion}/notifications/test`, + {method: 'post'}, + ); + }; }; export default ClientPosts; diff --git a/app/components/section_notice/__snapshots__/index.test.tsx.snap b/app/components/section_notice/__snapshots__/index.test.tsx.snap new file mode 100644 index 000000000..28b0cf993 --- /dev/null +++ b/app/components/section_notice/__snapshots__/index.test.tsx.snap @@ -0,0 +1,520 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Section notice match snapshot 1`] = ` + + + + + + Some title + + + + + Some text + + + + + + + + + + + primary button + + + + + + + + + + + + secondary button + + + + + + + + + + + + link button + + + + + + + + + + + + +`; diff --git a/app/components/section_notice/index.test.tsx b/app/components/section_notice/index.test.tsx new file mode 100644 index 000000000..498ee18d3 --- /dev/null +++ b/app/components/section_notice/index.test.tsx @@ -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 { + 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(, {database}); + expect(wrapper.toJSON()).toMatchSnapshot(); + }); + + it('should show buttons only if defined', () => { + const props = getBaseProps(); + const wrapper = renderWithEverything(, {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(); + 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(); + 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(); + 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(); + 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(, {database}); + let icon = wrapper.getByTestId('sectionNoticeHeaderIcon'); + expect(icon).toBeVisible(); + expect(icon.props).toHaveProperty('name', 'information-outline'); + + props.type = 'danger'; + wrapper.rerender(); + icon = wrapper.getByTestId('sectionNoticeHeaderIcon'); + expect(icon).toBeVisible(); + expect(icon.props).toHaveProperty('name', 'alert-outline'); + + props.type = 'hint'; + wrapper.rerender(); + icon = wrapper.getByTestId('sectionNoticeHeaderIcon'); + expect(icon).toBeVisible(); + expect(icon.props).toHaveProperty('name', 'lightbulb-outline'); + + props.type = 'success'; + wrapper.rerender(); + icon = wrapper.getByTestId('sectionNoticeHeaderIcon'); + expect(icon).toBeVisible(); + expect(icon.props).toHaveProperty('name', 'check'); + + props.type = 'warning'; + wrapper.rerender(); + icon = wrapper.getByTestId('sectionNoticeHeaderIcon'); + expect(icon).toBeVisible(); + expect(icon.props).toHaveProperty('name', 'alert-outline'); + + props.type = 'welcome'; + wrapper.rerender(); + 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(, {database}); + let container = wrapper.getByTestId('sectionNoticeContainer'); + expect(container).toHaveStyle({backgroundColor: 'rgba(93,137,234,0.08)'}); + + props.type = 'danger'; + wrapper.rerender(); + container = wrapper.getByTestId('sectionNoticeContainer'); + expect(container).toHaveStyle({backgroundColor: 'rgba(210,75,78,0.08)'}); + + props.type = 'hint'; + wrapper.rerender(); + container = wrapper.getByTestId('sectionNoticeContainer'); + expect(container).toHaveStyle({backgroundColor: 'rgba(93,137,234,0.08)'}); + + props.type = 'success'; + wrapper.rerender(); + container = wrapper.getByTestId('sectionNoticeContainer'); + expect(container).toHaveStyle({backgroundColor: 'rgba(61,184,135,0.08)'}); + + props.type = 'warning'; + wrapper.rerender(); + container = wrapper.getByTestId('sectionNoticeContainer'); + expect(container).toHaveStyle({backgroundColor: 'rgba(255,188,31,0.08)'}); + + props.type = 'welcome'; + wrapper.rerender(); + 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(, {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(); + }); +}); diff --git a/app/components/section_notice/index.tsx b/app/components/section_notice/index.tsx new file mode 100644 index 000000000..00323dd92 --- /dev/null +++ b/app/components/section_notice/index.tsx @@ -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 ( + + + {icon && ( + + )} + + {title} + {text && ( + + )} + {hasButtons && ( + + {primaryButton && ( + + )} + {secondaryButton && ( + + )} + {linkButton && ( + + )} + + )} + + + {showDismiss && ( + + + + )} + + ); +}; + +export default SectionNotice; diff --git a/app/components/section_notice/section_notice_button.tsx b/app/components/section_notice/section_notice_button.tsx new file mode 100644 index 000000000..e11d986f0 --- /dev/null +++ b/app/components/section_notice/section_notice_button.tsx @@ -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 ( +