Compare commits
12 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b10695c2b | ||
|
|
c76177884f | ||
|
|
981daa8bfd | ||
|
|
97e49acbbd | ||
|
|
28a93896ba | ||
|
|
6f60b110d6 | ||
|
|
3431f294dc | ||
|
|
c308a9af3d | ||
|
|
ea2fde604a | ||
|
|
3695e4c7fc | ||
|
|
6e39168a56 | ||
|
|
4b133022ca |
129 changed files with 3288 additions and 9411 deletions
|
|
@ -115,8 +115,8 @@ android {
|
||||||
applicationId "com.mattermost.rnbeta"
|
applicationId "com.mattermost.rnbeta"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 676
|
versionCode 685
|
||||||
versionName "2.33.0"
|
versionName "2.34.0"
|
||||||
testBuildType System.getProperty('testBuildType', 'debug')
|
testBuildType System.getProperty('testBuildType', 'debug')
|
||||||
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
|
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package com.mattermost.rnbeta
|
||||||
import android.content.res.Configuration
|
import android.content.res.Configuration
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
|
import androidx.core.view.WindowCompat
|
||||||
import com.facebook.react.ReactActivityDelegate
|
import com.facebook.react.ReactActivityDelegate
|
||||||
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint
|
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint
|
||||||
import com.facebook.react.defaults.DefaultReactActivityDelegate
|
import com.facebook.react.defaults.DefaultReactActivityDelegate
|
||||||
|
|
@ -37,6 +38,7 @@ class MainActivity : NavigationActivity() {
|
||||||
setHWKeyboardConnected()
|
setHWKeyboardConnected()
|
||||||
lastOrientation = this.resources.configuration.orientation
|
lastOrientation = this.resources.configuration.orientation
|
||||||
foldableObserver.onCreate()
|
foldableObserver.onCreate()
|
||||||
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStart() {
|
override fun onStart() {
|
||||||
|
|
|
||||||
|
|
@ -7,5 +7,14 @@
|
||||||
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
|
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
|
||||||
<item name="android:windowOptOutEdgeToEdgeEnforcement">true</item>
|
<item name="android:windowOptOutEdgeToEdgeEnforcement">true</item>
|
||||||
|
|
||||||
|
|
||||||
|
<item name="android:navigationBarDividerColor">@android:color/transparent</item>
|
||||||
|
|
||||||
|
<!-- Avoid forced contrast paddings on some OEMs -->
|
||||||
|
<item name="android:enforceStatusBarContrast">false</item>
|
||||||
|
<item name="android:enforceNavigationBarContrast">false</item>
|
||||||
|
|
||||||
|
<!-- Support cutouts -->
|
||||||
|
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||||
</style>
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import {Tutorial} from '@constants';
|
import {Tutorial} from '@constants';
|
||||||
import {GLOBAL_IDENTIFIERS} from '@constants/database';
|
|
||||||
import DatabaseManager from '@database/manager';
|
import DatabaseManager from '@database/manager';
|
||||||
import {
|
import {
|
||||||
getDeviceToken,
|
getDeviceToken,
|
||||||
|
|
@ -34,7 +33,6 @@ import {
|
||||||
removePushDisabledInServerAcknowledged,
|
removePushDisabledInServerAcknowledged,
|
||||||
storeScheduledPostTutorial,
|
storeScheduledPostTutorial,
|
||||||
storeScheduledPostsListTutorial,
|
storeScheduledPostsListTutorial,
|
||||||
storeLowConnectivityMonitor,
|
|
||||||
} from './global';
|
} from './global';
|
||||||
|
|
||||||
const serverUrl = 'server.test.com';
|
const serverUrl = 'server.test.com';
|
||||||
|
|
@ -205,17 +203,4 @@ describe('/app/actions/app/global', () => {
|
||||||
records = await queryGlobalValue(Tutorial.SCHEDULED_POSTS_LIST)?.fetch();
|
records = await queryGlobalValue(Tutorial.SCHEDULED_POSTS_LIST)?.fetch();
|
||||||
expect(records?.[0]?.value).toBe(true);
|
expect(records?.[0]?.value).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('storeLowConnectivityMonitor', async () => {
|
|
||||||
let records = await queryGlobalValue(GLOBAL_IDENTIFIERS.LOW_CONNECTIVITY_MONITOR)?.fetch();
|
|
||||||
expect(records?.[0]?.value).toBeUndefined();
|
|
||||||
|
|
||||||
await storeLowConnectivityMonitor(true);
|
|
||||||
records = await queryGlobalValue(GLOBAL_IDENTIFIERS.LOW_CONNECTIVITY_MONITOR)?.fetch();
|
|
||||||
expect(records?.[0]?.value).toBe(true);
|
|
||||||
|
|
||||||
await storeLowConnectivityMonitor(false);
|
|
||||||
records = await queryGlobalValue(GLOBAL_IDENTIFIERS.LOW_CONNECTIVITY_MONITOR)?.fetch();
|
|
||||||
expect(records?.[0]?.value).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,3 @@ export const storePushDisabledInServerAcknowledged = async (serverUrl: string) =
|
||||||
export const removePushDisabledInServerAcknowledged = async (serverUrl: string) => {
|
export const removePushDisabledInServerAcknowledged = async (serverUrl: string) => {
|
||||||
return storeGlobal(`${GLOBAL_IDENTIFIERS.PUSH_DISABLED_ACK}${serverUrl}`, null, false);
|
return storeGlobal(`${GLOBAL_IDENTIFIERS.PUSH_DISABLED_ACK}${serverUrl}`, null, false);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const storeLowConnectivityMonitor = async (enabled: boolean) => {
|
|
||||||
return storeGlobal(GLOBAL_IDENTIFIERS.LOW_CONNECTIVITY_MONITOR, enabled, false);
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ import {getCurrentUser, queryUsersById} from '@queries/servers/user';
|
||||||
import {dismissAllModalsAndPopToRoot, dismissAllModalsAndPopToScreen} from '@screens/navigation';
|
import {dismissAllModalsAndPopToRoot, dismissAllModalsAndPopToScreen} from '@screens/navigation';
|
||||||
import EphemeralStore from '@store/ephemeral_store';
|
import EphemeralStore from '@store/ephemeral_store';
|
||||||
import {isTablet} from '@utils/helpers';
|
import {isTablet} from '@utils/helpers';
|
||||||
import {logError, logInfo} from '@utils/log';
|
import {logDebug, logError, logInfo} from '@utils/log';
|
||||||
import {displayGroupMessageName, displayUsername, getUserIdFromChannelName} from '@utils/user';
|
import {displayGroupMessageName, displayUsername, getUserIdFromChannelName} from '@utils/user';
|
||||||
|
|
||||||
import type ServerDataOperator from '@database/operator/server_data_operator';
|
import type ServerDataOperator from '@database/operator/server_data_operator';
|
||||||
|
|
@ -88,14 +88,16 @@ export async function switchToChannel(serverUrl: string, channelId: string, team
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isTabletDevice) {
|
if (isTabletDevice) {
|
||||||
dismissAllModalsAndPopToRoot();
|
await dismissAllModalsAndPopToRoot();
|
||||||
DeviceEventEmitter.emit(NavigationConstants.NAVIGATION_HOME, Screens.CHANNEL);
|
DeviceEventEmitter.emit(NavigationConstants.NAVIGATION_HOME, Screens.CHANNEL);
|
||||||
} else {
|
} else {
|
||||||
dismissAllModalsAndPopToScreen(Screens.CHANNEL, '', undefined, {topBar: {visible: false}});
|
await dismissAllModalsAndPopToScreen(Screens.CHANNEL, '', undefined, {topBar: {visible: false}});
|
||||||
}
|
}
|
||||||
|
|
||||||
logInfo('channel switch to', channel?.displayName, channelId, (Date.now() - dt), 'ms');
|
logInfo('channel switch to', channel?.displayName, channelId, (Date.now() - dt), 'ms');
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
logDebug('failed to navigate to channel because there was no membership, channel id: ', channelId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {models};
|
return {models};
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import {getCurrentTeamId, getCurrentUserId, prepareCommonSystemValues, type Prep
|
||||||
import {addChannelToTeamHistory, addTeamToTeamHistory} from '@queries/servers/team';
|
import {addChannelToTeamHistory, addTeamToTeamHistory} from '@queries/servers/team';
|
||||||
import {getThreadById, prepareThreadsFromReceivedPosts, queryThreadsInTeam} from '@queries/servers/thread';
|
import {getThreadById, prepareThreadsFromReceivedPosts, queryThreadsInTeam} from '@queries/servers/thread';
|
||||||
import {getCurrentUser} from '@queries/servers/user';
|
import {getCurrentUser} from '@queries/servers/user';
|
||||||
import {dismissAllModals, dismissAllModalsAndPopToRoot, dismissAllOverlaysWithExceptions, goToScreen} from '@screens/navigation';
|
import {dismissAllModals, dismissAllModalsAndPopToRoot, dismissAllOverlays, goToScreen} from '@screens/navigation';
|
||||||
import EphemeralStore from '@store/ephemeral_store';
|
import EphemeralStore from '@store/ephemeral_store';
|
||||||
import NavigationStore from '@store/navigation_store';
|
import NavigationStore from '@store/navigation_store';
|
||||||
import {isTablet} from '@utils/helpers';
|
import {isTablet} from '@utils/helpers';
|
||||||
|
|
@ -95,7 +95,7 @@ export const switchToThread = async (serverUrl: string, rootId: string, isFromNo
|
||||||
if (isFromNotification) {
|
if (isFromNotification) {
|
||||||
if (currentThreadId && currentThreadId === rootId && NavigationStore.getScreensInStack().includes(Screens.THREAD)) {
|
if (currentThreadId && currentThreadId === rootId && NavigationStore.getScreensInStack().includes(Screens.THREAD)) {
|
||||||
await dismissAllModals();
|
await dismissAllModals();
|
||||||
await dismissAllOverlaysWithExceptions();
|
await dismissAllOverlays();
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ import {
|
||||||
joinChannelIfNeeded,
|
joinChannelIfNeeded,
|
||||||
markChannelAsRead,
|
markChannelAsRead,
|
||||||
unsetActiveChannelOnServer,
|
unsetActiveChannelOnServer,
|
||||||
switchToChannelByName,
|
joinIfNeededAndSwitchToChannel,
|
||||||
goToNPSChannel,
|
goToNPSChannel,
|
||||||
fetchMissingDirectChannelsInfo,
|
fetchMissingDirectChannelsInfo,
|
||||||
fetchDirectChannelsInfo,
|
fetchDirectChannelsInfo,
|
||||||
|
|
@ -434,14 +434,14 @@ describe('channel', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('switchToChannelByName - handle not found database', async () => {
|
it('switchToChannelByName - handle not found database', async () => {
|
||||||
const {error} = await switchToChannelByName('foo', '', '', () => {}, intl);
|
const {error} = await joinIfNeededAndSwitchToChannel('foo', {}, {}, () => {}, intl);
|
||||||
expect(error).toBeDefined();
|
expect(error).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('switchToChannelByName - base case', async () => {
|
it('switchToChannelByName - base case', async () => {
|
||||||
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user.id}, {id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, value: teamId}], prepareRecordsOnly: false});
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user.id}, {id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, value: teamId}], prepareRecordsOnly: false});
|
||||||
|
|
||||||
const result = await switchToChannelByName(serverUrl, 'channelname', 'teamname', () => {}, intl);
|
const result = await joinIfNeededAndSwitchToChannel(serverUrl, {name: 'channelname'}, {name: 'teamname'}, () => {}, intl);
|
||||||
expect(result).toBeDefined();
|
expect(result).toBeDefined();
|
||||||
expect(result).not.toHaveProperty('error');
|
expect(result).not.toHaveProperty('error');
|
||||||
});
|
});
|
||||||
|
|
@ -449,7 +449,7 @@ describe('channel', () => {
|
||||||
it('switchToChannelByName - team redirect', async () => {
|
it('switchToChannelByName - team redirect', async () => {
|
||||||
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user.id}, {id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, value: teamId}], prepareRecordsOnly: false});
|
await operator.handleSystem({systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: user.id}, {id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID, value: teamId}], prepareRecordsOnly: false});
|
||||||
|
|
||||||
const result = await switchToChannelByName(serverUrl, 'channelname', DeepLink.Redirect, () => {}, intl);
|
const result = await joinIfNeededAndSwitchToChannel(serverUrl, {name: 'channelname'}, {name: DeepLink.Redirect}, () => {}, intl);
|
||||||
expect(result).toBeDefined();
|
expect(result).toBeDefined();
|
||||||
expect(result).not.toHaveProperty('error');
|
expect(result).not.toHaveProperty('error');
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import {getActiveServer} from '@queries/app/servers';
|
||||||
import {prepareMyChannelsForTeam, getChannelById, getChannelByName, getMyChannel, getChannelInfo, queryMyChannelSettingsByIds, getMembersCountByChannelsId, deleteChannelMembership, queryChannelsById} from '@queries/servers/channel';
|
import {prepareMyChannelsForTeam, getChannelById, getChannelByName, getMyChannel, getChannelInfo, queryMyChannelSettingsByIds, getMembersCountByChannelsId, deleteChannelMembership, queryChannelsById} from '@queries/servers/channel';
|
||||||
import {queryDisplayNamePreferences} from '@queries/servers/preference';
|
import {queryDisplayNamePreferences} from '@queries/servers/preference';
|
||||||
import {getCommonSystemValues, getConfig, getCurrentChannelId, getCurrentTeamId, getCurrentUserId, getLicense, setCurrentChannelId, setCurrentTeamAndChannelId} from '@queries/servers/system';
|
import {getCommonSystemValues, getConfig, getCurrentChannelId, getCurrentTeamId, getCurrentUserId, getLicense, setCurrentChannelId, setCurrentTeamAndChannelId} from '@queries/servers/system';
|
||||||
import {getNthLastChannelFromTeam, getMyTeamById, getTeamByName, queryMyTeams, removeChannelFromTeamHistory} from '@queries/servers/team';
|
import {getNthLastChannelFromTeam, getMyTeamById, getTeamByName, queryMyTeams, removeChannelFromTeamHistory, getTeamById} from '@queries/servers/team';
|
||||||
import {getIsCRTEnabled} from '@queries/servers/thread';
|
import {getIsCRTEnabled} from '@queries/servers/thread';
|
||||||
import {getCurrentUser} from '@queries/servers/user';
|
import {getCurrentUser} from '@queries/servers/user';
|
||||||
import {dismissAllModalsAndPopToRoot} from '@screens/navigation';
|
import {dismissAllModalsAndPopToRoot} from '@screens/navigation';
|
||||||
|
|
@ -38,7 +38,7 @@ import {fetchPostsForChannel} from './post';
|
||||||
import {openChannelIfNeeded, savePreference} from './preference';
|
import {openChannelIfNeeded, savePreference} from './preference';
|
||||||
import {fetchRolesIfNeeded} from './role';
|
import {fetchRolesIfNeeded} from './role';
|
||||||
import {forceLogoutIfNecessary} from './session';
|
import {forceLogoutIfNecessary} from './session';
|
||||||
import {addCurrentUserToTeam, fetchTeamByName, removeCurrentUserFromTeam} from './team';
|
import {addCurrentUserToTeam, fetchTeamById, fetchTeamByName, removeCurrentUserFromTeam} from './team';
|
||||||
import {fetchProfilesInChannel, fetchProfilesInGroupChannels, fetchProfilesPerChannels, fetchUsersByIds, updateUsersNoLongerVisible} from './user';
|
import {fetchProfilesInChannel, fetchProfilesInGroupChannels, fetchProfilesPerChannels, fetchUsersByIds, updateUsersNoLongerVisible} from './user';
|
||||||
|
|
||||||
import type {Model} from '@nozbe/watermelondb';
|
import type {Model} from '@nozbe/watermelondb';
|
||||||
|
|
@ -763,16 +763,25 @@ export async function unsetActiveChannelOnServer(serverUrl: string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function switchToChannelByName(serverUrl: string, channelName: string, teamName: string, errorHandler: (intl: IntlShape) => void, intl: IntlShape) {
|
export async function joinIfNeededAndSwitchToChannel(
|
||||||
const onError = (joinedTeam: boolean, teamId?: string) => {
|
serverUrl: string,
|
||||||
|
channelInfo: {id?: string; name?: string},
|
||||||
|
teamInfo: {id?: string; name?: string} | undefined,
|
||||||
|
errorHandler: (intl: IntlShape) => void,
|
||||||
|
intl: IntlShape,
|
||||||
|
) {
|
||||||
|
const onError = (joinedTeam: boolean, teamIdToRemove?: string) => {
|
||||||
errorHandler(intl);
|
errorHandler(intl);
|
||||||
if (joinedTeam && teamId) {
|
if (joinedTeam && teamIdToRemove) {
|
||||||
removeCurrentUserFromTeam(serverUrl, teamId, false);
|
removeCurrentUserFromTeam(serverUrl, teamIdToRemove, false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let joinedTeam = false;
|
let joinedTeam = false;
|
||||||
let teamId = '';
|
let teamId = teamInfo?.id || '';
|
||||||
|
let channelId = channelInfo?.id || '';
|
||||||
|
const channelName = channelInfo?.name || '';
|
||||||
|
const teamName = teamInfo?.name || '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
||||||
|
|
@ -780,12 +789,14 @@ export async function switchToChannelByName(serverUrl: string, channelName: stri
|
||||||
if (teamName === DeepLink.Redirect) {
|
if (teamName === DeepLink.Redirect) {
|
||||||
teamId = await getCurrentTeamId(database);
|
teamId = await getCurrentTeamId(database);
|
||||||
} else {
|
} else {
|
||||||
const team = await getTeamByName(database, teamName);
|
const team = teamId ? await getTeamById(database, teamId) : await getTeamByName(database, teamName);
|
||||||
const isTeamMember = team ? await getMyTeamById(database, team.id) : false;
|
const isTeamMember = team ? Boolean(await getMyTeamById(database, team.id)) : false;
|
||||||
teamId = team?.id || '';
|
if (!teamId) {
|
||||||
|
teamId = team?.id || '';
|
||||||
|
}
|
||||||
|
|
||||||
if (!isTeamMember) {
|
if (!isTeamMember) {
|
||||||
const fetchRequest = await fetchTeamByName(serverUrl, teamName);
|
const fetchRequest = teamId ? await fetchTeamById(serverUrl, teamId) : await fetchTeamByName(serverUrl, teamName);
|
||||||
if (!fetchRequest.team) {
|
if (!fetchRequest.team) {
|
||||||
onError(joinedTeam);
|
onError(joinedTeam);
|
||||||
return {error: fetchRequest.error || 'no team received'};
|
return {error: fetchRequest.error || 'no team received'};
|
||||||
|
|
@ -800,11 +811,14 @@ export async function switchToChannelByName(serverUrl: string, channelName: stri
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const channel = await getChannelByName(database, teamId, channelName);
|
const channel = channelId ? await getChannelById(database, channelId) : await getChannelByName(database, teamId, channelName);
|
||||||
const isChannelMember = channel ? await getMyChannel(database, channel.id) : false;
|
const isChannelMember = channel ? await getMyChannel(database, channel.id) : false;
|
||||||
let channelId = channel?.id || '';
|
if (!channelId) {
|
||||||
|
channelId = channel?.id || '';
|
||||||
|
}
|
||||||
|
|
||||||
if (!isChannelMember) {
|
if (!isChannelMember) {
|
||||||
const fetchRequest = await fetchChannelByName(serverUrl, teamId, channelName, true);
|
const fetchRequest = channelId ? await fetchChannelById(serverUrl, channelId) : await fetchChannelByName(serverUrl, teamId, channelName, true);
|
||||||
if (!fetchRequest.channel) {
|
if (!fetchRequest.channel) {
|
||||||
onError(joinedTeam, teamId);
|
onError(joinedTeam, teamId);
|
||||||
return {error: fetchRequest.error || 'cannot fetch channel'};
|
return {error: fetchRequest.error || 'cannot fetch channel'};
|
||||||
|
|
@ -818,7 +832,7 @@ export async function switchToChannelByName(serverUrl: string, channelName: stri
|
||||||
}
|
}
|
||||||
|
|
||||||
logInfo('joining channel', fetchRequest.channel.display_name, fetchRequest.channel.id);
|
logInfo('joining channel', fetchRequest.channel.display_name, fetchRequest.channel.id);
|
||||||
const joinRequest = await joinChannel(serverUrl, teamId, undefined, channelName, false);
|
const joinRequest = await joinChannel(serverUrl, teamId, channelId, channelName, false);
|
||||||
if (!joinRequest.channel) {
|
if (!joinRequest.channel) {
|
||||||
onError(joinedTeam, teamId);
|
onError(joinedTeam, teamId);
|
||||||
return {error: joinRequest.error || 'no channel returned from join'};
|
return {error: joinRequest.error || 'no channel returned from join'};
|
||||||
|
|
@ -827,7 +841,7 @@ export async function switchToChannelByName(serverUrl: string, channelName: stri
|
||||||
channelId = fetchRequest.channel.id;
|
channelId = fetchRequest.channel.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
switchToChannelById(serverUrl, channelId, teamId);
|
await switchToChannelById(serverUrl, channelId, teamId);
|
||||||
return {};
|
return {};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onError(joinedTeam, teamId);
|
onError(joinedTeam, teamId);
|
||||||
|
|
|
||||||
|
|
@ -31,13 +31,7 @@ jest.mock('@actions/remote/user');
|
||||||
jest.mock('@actions/remote/post');
|
jest.mock('@actions/remote/post');
|
||||||
jest.mock('@actions/remote/groups');
|
jest.mock('@actions/remote/groups');
|
||||||
jest.mock('@actions/remote/thread');
|
jest.mock('@actions/remote/thread');
|
||||||
jest.mock('@queries/app/global', () => {
|
jest.mock('@queries/app/global');
|
||||||
const {of: mockOf} = require('rxjs');
|
|
||||||
return {
|
|
||||||
getDeviceToken: jest.fn(),
|
|
||||||
observeLowConnectivityMonitor: jest.fn(() => mockOf(true)),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
jest.mock('@queries/servers/system');
|
jest.mock('@queries/servers/system');
|
||||||
jest.mock('@queries/servers/entry');
|
jest.mock('@queries/servers/entry');
|
||||||
jest.mock('@queries/servers/system', () => {
|
jest.mock('@queries/servers/system', () => {
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
sendEmailInvitesToTeam,
|
sendEmailInvitesToTeam,
|
||||||
fetchMyTeams,
|
fetchMyTeams,
|
||||||
fetchMyTeam,
|
fetchMyTeam,
|
||||||
|
fetchTeamById,
|
||||||
fetchAllTeams,
|
fetchAllTeams,
|
||||||
fetchTeamsForComponent,
|
fetchTeamsForComponent,
|
||||||
updateCanJoinTeams,
|
updateCanJoinTeams,
|
||||||
|
|
@ -251,6 +252,14 @@ describe('teams', () => {
|
||||||
expect(result.memberships).toBeDefined();
|
expect(result.memberships).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('fetchTeamById - base case', async () => {
|
||||||
|
const result = await fetchTeamById(serverUrl, teamId);
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result.team).toBeDefined();
|
||||||
|
expect(result.team?.id).toBe(teamId);
|
||||||
|
expect(mockClient.getTeam).toHaveBeenCalledWith(teamId);
|
||||||
|
});
|
||||||
|
|
||||||
it('fetchAllTeams - base case', async () => {
|
it('fetchAllTeams - base case', async () => {
|
||||||
const result = await fetchAllTeams(serverUrl);
|
const result = await fetchAllTeams(serverUrl);
|
||||||
expect(result).toBeDefined();
|
expect(result).toBeDefined();
|
||||||
|
|
|
||||||
|
|
@ -205,6 +205,18 @@ export async function fetchMyTeams(serverUrl: string, fetchOnly = false, groupLa
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchTeamById(serverUrl: string, teamId: string) {
|
||||||
|
try {
|
||||||
|
const client = NetworkManager.getClient(serverUrl);
|
||||||
|
const team = await client.getTeam(teamId);
|
||||||
|
return {team};
|
||||||
|
} catch (error) {
|
||||||
|
logDebug('error on fetchTeamById', getFullErrorMessage(error));
|
||||||
|
forceLogoutIfNecessary(serverUrl, error);
|
||||||
|
return {error};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchMyTeam(serverUrl: string, teamId: string, fetchOnly = false, groupLabel?: RequestGroupLabel): Promise<MyTeamsRequest> {
|
export async function fetchMyTeam(serverUrl: string, teamId: string, fetchOnly = false, groupLabel?: RequestGroupLabel): Promise<MyTeamsRequest> {
|
||||||
try {
|
try {
|
||||||
const client = NetworkManager.getClient(serverUrl);
|
const client = NetworkManager.getClient(serverUrl);
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,6 @@ import {DeviceEventEmitter} from 'react-native';
|
||||||
|
|
||||||
import LocalConfig from '@assets/config.json';
|
import LocalConfig from '@assets/config.json';
|
||||||
import {Events} from '@constants';
|
import {Events} from '@constants';
|
||||||
import NetworkPerformanceManager from '@managers/network_performance_manager';
|
|
||||||
import PerformanceMetricsManager from '@managers/performance_metrics_manager';
|
|
||||||
import test_helper from '@test/test_helper';
|
import test_helper from '@test/test_helper';
|
||||||
|
|
||||||
import * as ClientConstants from './constants';
|
import * as ClientConstants from './constants';
|
||||||
|
|
@ -64,18 +62,7 @@ jest.mock('@managers/performance_metrics_manager', () => ({
|
||||||
collectNetworkRequestData: jest.fn(),
|
collectNetworkRequestData: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('@managers/network_performance_manager', () => ({
|
|
||||||
__esModule: true,
|
|
||||||
default: {
|
|
||||||
startRequestTracking: jest.fn(() => 'mock-request-id-123'),
|
|
||||||
completeRequestTracking: jest.fn(),
|
|
||||||
cancelRequestTracking: jest.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('ClientTracking', () => {
|
describe('ClientTracking', () => {
|
||||||
const mockedNPM = jest.mocked(NetworkPerformanceManager);
|
|
||||||
const mockedPMM = jest.mocked(PerformanceMetricsManager);
|
|
||||||
const apiClientMock = {
|
const apiClientMock = {
|
||||||
baseUrl: 'https://example.com',
|
baseUrl: 'https://example.com',
|
||||||
get: jest.fn(),
|
get: jest.fn(),
|
||||||
|
|
@ -890,109 +877,5 @@ describe('ClientTracking', () => {
|
||||||
expect(client.requestHeaders[ClientConstants.HEADER_X_MATTERMOST_PREAUTH_SECRET]).toBeUndefined();
|
expect(client.requestHeaders[ClientConstants.HEADER_X_MATTERMOST_PREAUTH_SECRET]).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Network Performance Tracking', () => {
|
|
||||||
const createMockMetrics = (overrides = {}) => ({
|
|
||||||
latency: 500,
|
|
||||||
size: 1000,
|
|
||||||
compressedSize: 500,
|
|
||||||
startTime: Date.now(),
|
|
||||||
endTime: Date.now() + 500,
|
|
||||||
speedInMbps: 1,
|
|
||||||
networkType: 'Wi-Fi',
|
|
||||||
tlsCipherSuite: 'none',
|
|
||||||
tlsVersion: 'none',
|
|
||||||
isCached: false,
|
|
||||||
httpVersion: 'h2',
|
|
||||||
connectionTime: 0,
|
|
||||||
...overrides,
|
|
||||||
});
|
|
||||||
|
|
||||||
const mockSuccessResponse = (metrics = createMockMetrics()) => ({
|
|
||||||
ok: true,
|
|
||||||
data: {success: true},
|
|
||||||
headers: {},
|
|
||||||
metrics,
|
|
||||||
});
|
|
||||||
|
|
||||||
const requestOptions = {
|
|
||||||
method: 'GET',
|
|
||||||
groupLabel: 'Cold Start' as RequestGroupLabel,
|
|
||||||
};
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call full tracking lifecycle', async () => {
|
|
||||||
const mockMetrics = createMockMetrics();
|
|
||||||
apiClientMock.get.mockResolvedValue(mockSuccessResponse(mockMetrics));
|
|
||||||
|
|
||||||
await client.doFetchWithTracking('https://example.com/api', requestOptions);
|
|
||||||
|
|
||||||
expect(mockedNPM.startRequestTracking).toHaveBeenCalledWith('https://example.com', 'https://example.com/api');
|
|
||||||
expect(mockedNPM.completeRequestTracking).toHaveBeenCalledWith('https://example.com', 'mock-request-id-123', mockMetrics);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not call completeRequestTracking when response.metrics is undefined', async () => {
|
|
||||||
apiClientMock.get.mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
data: {success: true},
|
|
||||||
headers: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
await client.doFetchWithTracking('https://example.com/api', requestOptions);
|
|
||||||
|
|
||||||
expect(mockedNPM.startRequestTracking).toHaveBeenCalledWith('https://example.com', 'https://example.com/api');
|
|
||||||
expect(mockedNPM.completeRequestTracking).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call tracking methods independently of CollectNetworkMetrics', async () => {
|
|
||||||
LocalConfig.CollectNetworkMetrics = false;
|
|
||||||
const mockMetrics = createMockMetrics();
|
|
||||||
apiClientMock.get.mockResolvedValue(mockSuccessResponse(mockMetrics));
|
|
||||||
|
|
||||||
await client.doFetchWithTracking('https://example.com/api', requestOptions);
|
|
||||||
|
|
||||||
expect(mockedPMM.collectNetworkRequestData).not.toHaveBeenCalled();
|
|
||||||
expect(mockedNPM.startRequestTracking).toHaveBeenCalledWith('https://example.com', 'https://example.com/api');
|
|
||||||
expect(mockedNPM.completeRequestTracking).toHaveBeenCalledWith('https://example.com', 'mock-request-id-123', mockMetrics);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call tracking methods when both flags are enabled', async () => {
|
|
||||||
LocalConfig.CollectNetworkMetrics = true;
|
|
||||||
const mockMetrics = createMockMetrics();
|
|
||||||
apiClientMock.get.mockResolvedValue(mockSuccessResponse(mockMetrics));
|
|
||||||
|
|
||||||
await client.doFetchWithTracking('https://example.com/api', requestOptions);
|
|
||||||
|
|
||||||
expect(mockedNPM.startRequestTracking).toHaveBeenCalledWith('https://example.com', 'https://example.com/api');
|
|
||||||
expect(mockedNPM.completeRequestTracking).toHaveBeenCalledWith('https://example.com', 'mock-request-id-123', mockMetrics);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should pass correct server URL to NetworkPerformanceManager', async () => {
|
|
||||||
const customBaseUrl = 'https://custom-server.com';
|
|
||||||
const customApiClient = {...apiClientMock, baseUrl: customBaseUrl};
|
|
||||||
const customClient = new ClientTracking(customApiClient as unknown as APIClientInterface);
|
|
||||||
const mockMetrics = createMockMetrics({latency: 300, size: 2000, compressedSize: 1000, speedInMbps: 2});
|
|
||||||
|
|
||||||
customApiClient.get.mockResolvedValue(mockSuccessResponse(mockMetrics));
|
|
||||||
|
|
||||||
await customClient.doFetchWithTracking('https://custom-server.com/api', requestOptions);
|
|
||||||
|
|
||||||
expect(mockedNPM.startRequestTracking).toHaveBeenCalledWith(customBaseUrl, 'https://custom-server.com/api');
|
|
||||||
expect(mockedNPM.completeRequestTracking).toHaveBeenCalledWith(customBaseUrl, 'mock-request-id-123', mockMetrics);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call cancelRequestTracking when request fails', async () => {
|
|
||||||
apiClientMock.get.mockRejectedValue(new Error('Request failed'));
|
|
||||||
|
|
||||||
await expect(client.doFetchWithTracking('https://example.com/api', requestOptions)).rejects.toThrow('Received invalid response from the server.');
|
|
||||||
|
|
||||||
expect(mockedNPM.startRequestTracking).toHaveBeenCalledWith('https://example.com', 'https://example.com/api');
|
|
||||||
expect(mockedNPM.cancelRequestTracking).toHaveBeenCalledWith('https://example.com', 'mock-request-id-123');
|
|
||||||
expect(mockedNPM.completeRequestTracking).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
/* eslint-enable max-lines */
|
/* eslint-enable max-lines */
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import {DeviceEventEmitter, Platform} from 'react-native';
|
||||||
import {CollectNetworkMetrics} from '@assets/config.json';
|
import {CollectNetworkMetrics} from '@assets/config.json';
|
||||||
import {Events} from '@constants';
|
import {Events} from '@constants';
|
||||||
import {setServerCredentials} from '@init/credentials';
|
import {setServerCredentials} from '@init/credentials';
|
||||||
import NetworkPerformanceManager from '@managers/network_performance_manager';
|
|
||||||
import PerformanceMetricsManager from '@managers/performance_metrics_manager';
|
import PerformanceMetricsManager from '@managers/performance_metrics_manager';
|
||||||
import {NetworkRequestMetrics} from '@managers/performance_metrics_manager/constant';
|
import {NetworkRequestMetrics} from '@managers/performance_metrics_manager/constant';
|
||||||
import {isErrorWithStatusCode} from '@utils/errors';
|
import {isErrorWithStatusCode} from '@utils/errors';
|
||||||
|
|
@ -132,24 +131,6 @@ export default class ClientTracking {
|
||||||
group.totalCompressedSize += metrics?.compressedSize ?? 0;
|
group.totalCompressedSize += metrics?.compressedSize ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
startNetworkPerformanceTracking(url: string): string | undefined {
|
|
||||||
return NetworkPerformanceManager.startRequestTracking(this.apiClient.baseUrl, url);
|
|
||||||
}
|
|
||||||
|
|
||||||
completeNetworkPerformanceTracking(requestId: string | undefined, url: string, metrics: ClientResponseMetrics | undefined) {
|
|
||||||
if (!requestId || !metrics) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
NetworkPerformanceManager.completeRequestTracking(this.apiClient.baseUrl, requestId, metrics);
|
|
||||||
}
|
|
||||||
|
|
||||||
cancelNetworkPerformanceTracking(requestId: string | undefined) {
|
|
||||||
if (!requestId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
NetworkPerformanceManager.cancelRequestTracking(this.apiClient.baseUrl, requestId);
|
|
||||||
}
|
|
||||||
|
|
||||||
getAverageLatency(groupLabel: RequestGroupLabel): number {
|
getAverageLatency(groupLabel: RequestGroupLabel): number {
|
||||||
const groupData = this.requestGroups.get(groupLabel);
|
const groupData = this.requestGroups.get(groupLabel);
|
||||||
if (!groupData) {
|
if (!groupData) {
|
||||||
|
|
@ -402,13 +383,10 @@ export default class ClientTracking {
|
||||||
this.incrementRequestCount(groupLabel);
|
this.incrementRequestCount(groupLabel);
|
||||||
}
|
}
|
||||||
|
|
||||||
const performanceRequestId = this.startNetworkPerformanceTracking(url);
|
|
||||||
|
|
||||||
let response: ClientResponse;
|
let response: ClientResponse;
|
||||||
try {
|
try {
|
||||||
response = await request!(url, this.buildRequestOptions(options));
|
response = await request!(url, this.buildRequestOptions(options));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.cancelNetworkPerformanceTracking(performanceRequestId);
|
|
||||||
const response_error = error as ClientError;
|
const response_error = error as ClientError;
|
||||||
const status_code = isErrorWithStatusCode(error) ? error.status_code : undefined;
|
const status_code = isErrorWithStatusCode(error) ? error.status_code : undefined;
|
||||||
throw new ClientError(this.apiClient.baseUrl, {
|
throw new ClientError(this.apiClient.baseUrl, {
|
||||||
|
|
@ -431,7 +409,6 @@ export default class ClientTracking {
|
||||||
if (groupLabel && CollectNetworkMetrics) {
|
if (groupLabel && CollectNetworkMetrics) {
|
||||||
this.trackRequest(groupLabel, url, response.metrics);
|
this.trackRequest(groupLabel, url, response.metrics);
|
||||||
}
|
}
|
||||||
this.completeNetworkPerformanceTracking(performanceRequestId, url, response.metrics);
|
|
||||||
const serverVersion = semverFromServerVersion(
|
const serverVersion = semverFromServerVersion(
|
||||||
headers[ClientConstants.HEADER_X_VERSION_ID] || headers[ClientConstants.HEADER_X_VERSION_ID.toLowerCase()],
|
headers[ClientConstants.HEADER_X_VERSION_ID] || headers[ClientConstants.HEADER_X_VERSION_ID.toLowerCase()],
|
||||||
);
|
);
|
||||||
|
|
@ -458,6 +435,7 @@ export default class ClientTracking {
|
||||||
server_error_id: response.data?.id as string,
|
server_error_id: response.data?.id as string,
|
||||||
status_code: response.code,
|
status_code: response.code,
|
||||||
url,
|
url,
|
||||||
|
headers,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,113 +0,0 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
||||||
// See LICENSE.txt for license information.
|
|
||||||
|
|
||||||
import {render, screen} from '@testing-library/react-native';
|
|
||||||
import React from 'react';
|
|
||||||
import {Text} from 'react-native';
|
|
||||||
|
|
||||||
import Banner from './Banner';
|
|
||||||
import {useBanner} from './hooks/useBanner';
|
|
||||||
|
|
||||||
jest.mock('./hooks/useBanner');
|
|
||||||
|
|
||||||
jest.mock('react-native-reanimated', () => {
|
|
||||||
const {View} = require('react-native');
|
|
||||||
return {
|
|
||||||
__esModule: true,
|
|
||||||
default: {View},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
jest.mock('react-native-gesture-handler', () => {
|
|
||||||
const ReactLib = require('react');
|
|
||||||
return {
|
|
||||||
GestureDetector: ({children}: {children: React.ReactNode}) => {
|
|
||||||
return ReactLib.createElement('View', {testID: 'gesture-detector'}, children);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Banner', () => {
|
|
||||||
const mockUseBanner = jest.mocked(useBanner);
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
mockUseBanner.mockReturnValue({
|
|
||||||
animatedStyle: {
|
|
||||||
opacity: 1,
|
|
||||||
transform: [{translateY: 0}, {translateX: 0}],
|
|
||||||
},
|
|
||||||
swipeGesture: {} as unknown as ReturnType<typeof useBanner>['swipeGesture'],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders children and animated view', () => {
|
|
||||||
render(
|
|
||||||
<Banner>
|
|
||||||
<Text testID='banner-content'>{'Test Content'}</Text>
|
|
||||||
</Banner>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByTestId('banner-content')).toBeTruthy();
|
|
||||||
expect(screen.getByTestId('banner-animated-view')).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not use GestureDetector when not dismissible', () => {
|
|
||||||
render(
|
|
||||||
<Banner dismissible={false}>
|
|
||||||
<Text testID='banner-content'>{'Test Content'}</Text>
|
|
||||||
</Banner>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.queryByTestId('gesture-detector')).toBeNull();
|
|
||||||
expect(screen.getByTestId('banner-content')).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses GestureDetector when dismissible', () => {
|
|
||||||
render(
|
|
||||||
<Banner dismissible={true}>
|
|
||||||
<Text testID='banner-content'>{'Test Content'}</Text>
|
|
||||||
</Banner>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByTestId('gesture-detector')).toBeTruthy();
|
|
||||||
expect(screen.getByTestId('banner-content')).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('passes correct props to useBanner hook', () => {
|
|
||||||
const onDismiss = jest.fn();
|
|
||||||
|
|
||||||
render(
|
|
||||||
<Banner
|
|
||||||
animationDuration={300}
|
|
||||||
dismissible={true}
|
|
||||||
onDismiss={onDismiss}
|
|
||||||
swipeThreshold={150}
|
|
||||||
>
|
|
||||||
<Text testID='banner-content'>{'Test Content'}</Text>
|
|
||||||
</Banner>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(mockUseBanner).toHaveBeenCalledWith({
|
|
||||||
animationDuration: 300,
|
|
||||||
dismissible: true,
|
|
||||||
swipeThreshold: 150,
|
|
||||||
onDismiss,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses default prop values when not provided', () => {
|
|
||||||
render(
|
|
||||||
<Banner>
|
|
||||||
<Text testID='banner-content'>{'Test Content'}</Text>
|
|
||||||
</Banner>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(mockUseBanner).toHaveBeenCalledWith({
|
|
||||||
animationDuration: 250,
|
|
||||||
dismissible: false,
|
|
||||||
swipeThreshold: 100,
|
|
||||||
onDismiss: undefined,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
||||||
// See LICENSE.txt for license information.
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import {StyleSheet} from 'react-native';
|
|
||||||
import {GestureDetector} from 'react-native-gesture-handler';
|
|
||||||
import Animated from 'react-native-reanimated';
|
|
||||||
|
|
||||||
import {useBanner} from './hooks/useBanner';
|
|
||||||
|
|
||||||
import type {BannerProps} from './types';
|
|
||||||
|
|
||||||
export type {BannerProps};
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
wrapper: {
|
|
||||||
width: '100%',
|
|
||||||
gap: 8,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Banner - A flexible banner component
|
|
||||||
*
|
|
||||||
* Renders content with fade and slide animations. Supports swipe-to-dismiss functionality.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* <Banner
|
|
||||||
* dismissible={true}
|
|
||||||
* onDismiss={() => console.log('Banner dismissed!')}
|
|
||||||
* >
|
|
||||||
* <Text>Your banner content</Text>
|
|
||||||
* </Banner>
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
const Banner: React.FC<BannerProps> = ({
|
|
||||||
children,
|
|
||||||
animationDuration = 250,
|
|
||||||
style,
|
|
||||||
dismissible = false,
|
|
||||||
onDismiss,
|
|
||||||
swipeThreshold = 100,
|
|
||||||
}) => {
|
|
||||||
const {animatedStyle, swipeGesture} = useBanner({
|
|
||||||
animationDuration,
|
|
||||||
dismissible,
|
|
||||||
swipeThreshold,
|
|
||||||
onDismiss,
|
|
||||||
});
|
|
||||||
|
|
||||||
const bannerContent = (
|
|
||||||
<Animated.View
|
|
||||||
testID='banner-animated-view'
|
|
||||||
style={[
|
|
||||||
styles.wrapper,
|
|
||||||
animatedStyle,
|
|
||||||
style,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Animated.View>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (dismissible) {
|
|
||||||
return (
|
|
||||||
<GestureDetector gesture={swipeGesture}>
|
|
||||||
{bannerContent}
|
|
||||||
</GestureDetector>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return bannerContent;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Banner;
|
|
||||||
|
|
@ -1,393 +0,0 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
||||||
// See LICENSE.txt for license information.
|
|
||||||
|
|
||||||
import {render, fireEvent, screen} from '@testing-library/react-native';
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
import BannerItem, {type BannerItemConfig} from './banner_item';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* BRANCH COVERAGE NOTE: This test suite achieves 85.71% branch coverage (30/35 branches).
|
|
||||||
*
|
|
||||||
* The remaining 5 uncovered branches are architecturally unreachable due to defensive
|
|
||||||
* programming patterns and React Native's internal behavior:
|
|
||||||
*
|
|
||||||
* 1. Line 131 - `if (onDismiss)` false branch: The handleDismiss function is only
|
|
||||||
* called when the dismiss button is pressed, but the dismiss button only renders
|
|
||||||
* when onDismiss exists. It's impossible to call handleDismiss with falsy onDismiss.
|
|
||||||
*
|
|
||||||
* 2. Line 142 - `pressed && (banner.onPress || onPress) && styles.pressed`: When
|
|
||||||
* pressed=true but no handlers exist, the component is disabled=true, so React
|
|
||||||
* Native prevents the pressed state entirely.
|
|
||||||
*
|
|
||||||
* 3. Line 168 - `pressed && styles.dismissPressed`: Similar to above - when the
|
|
||||||
* dismiss button exists, it's always pressable, so pressed state is controlled
|
|
||||||
* by React Native's internal logic.
|
|
||||||
*
|
|
||||||
* COULD WE MOCK THESE SITUATIONS?
|
|
||||||
* While technically possible through complex mocking (e.g., mocking React Native's Pressable
|
|
||||||
* to force pressed=true on disabled components), doing so would:
|
|
||||||
* - Test artificial scenarios that can never occur in production
|
|
||||||
* - Reduce test reliability by testing framework internals rather than component behavior
|
|
||||||
* - Create brittle tests that break when React Native updates its internal logic
|
|
||||||
* - Violate the principle of testing realistic user interactions
|
|
||||||
*
|
|
||||||
* These uncovered branches represent defensive code that prevents runtime errors
|
|
||||||
* but cannot be reached through normal component usage. 85.71% coverage indicates
|
|
||||||
* comprehensive testing of all realistic execution paths.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Mock dependencies
|
|
||||||
jest.mock('@components/compass_icon', () => {
|
|
||||||
const {Text} = require('react-native');
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
return function MockCompassIcon({name, size, color, testID}: any) {
|
|
||||||
const ReactLib = require('react');
|
|
||||||
return ReactLib.createElement(Text, {
|
|
||||||
testID: testID || 'compass-icon',
|
|
||||||
}, `Icon: ${name}, Size: ${size}, Color: ${color}`);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
jest.mock('@context/theme', () => ({
|
|
||||||
useTheme: jest.fn(() => ({
|
|
||||||
centerChannelBg: '#ffffff',
|
|
||||||
centerChannelColor: '#000000',
|
|
||||||
linkColor: '#0066cc',
|
|
||||||
errorTextColor: '#d24b47',
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('@utils/theme', () => ({
|
|
||||||
changeOpacity: jest.fn((color: string, opacity: number) => `${color}(${opacity})`),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('@utils/typography', () => ({
|
|
||||||
typography: jest.fn(() => ({
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: 'normal',
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('BannerItem', () => {
|
|
||||||
const mockOnPress = jest.fn();
|
|
||||||
const mockOnDismiss = jest.fn();
|
|
||||||
const mockBannerOnPress = jest.fn();
|
|
||||||
|
|
||||||
const createMockBanner = (overrides: Partial<BannerItemConfig> = {}): BannerItemConfig => ({
|
|
||||||
id: 'test-banner-1',
|
|
||||||
title: 'Test Banner Title',
|
|
||||||
message: 'This is a test banner message',
|
|
||||||
type: 'info',
|
|
||||||
dismissible: true,
|
|
||||||
onPress: mockBannerOnPress,
|
|
||||||
...overrides,
|
|
||||||
});
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const renderBannerItem = (banner: BannerItemConfig, props: {onPress?: any; onDismiss?: any} = {}) => {
|
|
||||||
return render(
|
|
||||||
<BannerItem
|
|
||||||
banner={banner}
|
|
||||||
onPress={props.onPress}
|
|
||||||
onDismiss={props.onDismiss}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('rendering', () => {
|
|
||||||
it('should render banner with title and message', () => {
|
|
||||||
const banner = createMockBanner();
|
|
||||||
renderBannerItem(banner);
|
|
||||||
|
|
||||||
expect(screen.getByText('Test Banner Title')).toBeTruthy();
|
|
||||||
expect(screen.getByText('This is a test banner message')).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render correct icon for each banner type', () => {
|
|
||||||
const types = [
|
|
||||||
{type: 'info', expectedIcon: 'information'},
|
|
||||||
{type: 'success', expectedIcon: 'check-circle'},
|
|
||||||
{type: 'warning', expectedIcon: 'alert'},
|
|
||||||
{type: 'error', expectedIcon: 'alert-circle'},
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
types.forEach(({type, expectedIcon}) => {
|
|
||||||
const banner = createMockBanner({type});
|
|
||||||
const {unmount} = renderBannerItem(banner);
|
|
||||||
|
|
||||||
expect(screen.getByText(new RegExp(`Icon: ${expectedIcon}`))).toBeTruthy();
|
|
||||||
unmount();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render default info icon when type is not provided', () => {
|
|
||||||
const banner = createMockBanner({type: undefined});
|
|
||||||
renderBannerItem(banner);
|
|
||||||
|
|
||||||
expect(screen.getByText(/Icon: information/)).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render dismiss button when dismissible and onDismiss is provided', () => {
|
|
||||||
const banner = createMockBanner({dismissible: true});
|
|
||||||
renderBannerItem(banner, {onDismiss: mockOnDismiss});
|
|
||||||
|
|
||||||
expect(screen.getByText(/Icon: close/)).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not render dismiss button when dismissible is false', () => {
|
|
||||||
const banner = createMockBanner({dismissible: false});
|
|
||||||
renderBannerItem(banner, {onDismiss: mockOnDismiss});
|
|
||||||
|
|
||||||
expect(screen.queryByText(/Icon: close/)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not render dismiss button when onDismiss is not provided', () => {
|
|
||||||
const banner = createMockBanner({dismissible: true});
|
|
||||||
renderBannerItem(banner);
|
|
||||||
|
|
||||||
expect(screen.queryByText(/Icon: close/)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render dismiss button by default when onDismiss is provided', () => {
|
|
||||||
const banner = createMockBanner({dismissible: undefined});
|
|
||||||
renderBannerItem(banner, {onDismiss: mockOnDismiss});
|
|
||||||
|
|
||||||
expect(screen.getByText(/Icon: close/)).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('press interactions', () => {
|
|
||||||
it('should call both banner.onPress and onPress prop when pressed', () => {
|
|
||||||
const banner = createMockBanner();
|
|
||||||
renderBannerItem(banner, {onPress: mockOnPress});
|
|
||||||
|
|
||||||
const bannerContainer = screen.getByTestId('banner-item-test-banner-1');
|
|
||||||
fireEvent.press(bannerContainer);
|
|
||||||
|
|
||||||
expect(mockBannerOnPress).toHaveBeenCalledTimes(1);
|
|
||||||
expect(mockOnPress).toHaveBeenCalledWith(banner);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call only onPress prop when banner.onPress is not provided', () => {
|
|
||||||
const banner = createMockBanner({onPress: undefined});
|
|
||||||
renderBannerItem(banner, {onPress: mockOnPress});
|
|
||||||
|
|
||||||
const bannerContainer = screen.getByTestId('banner-item-test-banner-1');
|
|
||||||
fireEvent.press(bannerContainer);
|
|
||||||
|
|
||||||
expect(mockBannerOnPress).not.toHaveBeenCalled();
|
|
||||||
expect(mockOnPress).toHaveBeenCalledWith(banner);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call only banner.onPress when onPress prop is not provided', () => {
|
|
||||||
const banner = createMockBanner();
|
|
||||||
renderBannerItem(banner);
|
|
||||||
|
|
||||||
const bannerContainer = screen.getByTestId('banner-item-test-banner-1');
|
|
||||||
fireEvent.press(bannerContainer);
|
|
||||||
|
|
||||||
expect(mockBannerOnPress).toHaveBeenCalledTimes(1);
|
|
||||||
expect(mockOnPress).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not respond to press when neither onPress handlers are provided', () => {
|
|
||||||
const banner = createMockBanner({onPress: undefined});
|
|
||||||
renderBannerItem(banner);
|
|
||||||
|
|
||||||
const bannerContainer = screen.getByTestId('banner-item-test-banner-1');
|
|
||||||
fireEvent.press(bannerContainer);
|
|
||||||
|
|
||||||
// Neither handler should be called
|
|
||||||
expect(mockBannerOnPress).not.toHaveBeenCalled();
|
|
||||||
expect(mockOnPress).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not disable press when banner.onPress is provided', () => {
|
|
||||||
const banner = createMockBanner();
|
|
||||||
renderBannerItem(banner);
|
|
||||||
|
|
||||||
const bannerContainer = screen.getByTestId('banner-item-test-banner-1');
|
|
||||||
|
|
||||||
expect(bannerContainer.props.disabled).toBeFalsy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not disable press when onPress prop is provided', () => {
|
|
||||||
const banner = createMockBanner({onPress: undefined});
|
|
||||||
renderBannerItem(banner, {onPress: mockOnPress});
|
|
||||||
|
|
||||||
const bannerContainer = screen.getByTestId('banner-item-test-banner-1');
|
|
||||||
|
|
||||||
expect(bannerContainer.props.disabled).toBeFalsy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('dismiss interactions', () => {
|
|
||||||
it('should call onDismiss with banner when dismiss button is pressed', () => {
|
|
||||||
const banner = createMockBanner();
|
|
||||||
renderBannerItem(banner, {onDismiss: mockOnDismiss});
|
|
||||||
|
|
||||||
const dismissButton = screen.getByTestId('banner-dismiss-test-banner-1');
|
|
||||||
fireEvent.press(dismissButton);
|
|
||||||
|
|
||||||
expect(mockOnDismiss).toHaveBeenCalledWith(banner);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not call onDismiss when dismiss button is not present', () => {
|
|
||||||
const banner = createMockBanner({dismissible: false});
|
|
||||||
renderBannerItem(banner, {onDismiss: mockOnDismiss});
|
|
||||||
|
|
||||||
// Try to find and press dismiss button (should not exist)
|
|
||||||
const dismissButton = screen.queryByText(/Icon: close/);
|
|
||||||
expect(dismissButton).toBeNull();
|
|
||||||
expect(mockOnDismiss).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle dismiss when onDismiss is not provided', () => {
|
|
||||||
const banner = createMockBanner();
|
|
||||||
renderBannerItem(banner); // No onDismiss prop
|
|
||||||
|
|
||||||
// Should not render dismiss button when onDismiss is not provided
|
|
||||||
const dismissButton = screen.queryByText(/Icon: close/);
|
|
||||||
expect(dismissButton).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('icon colors', () => {
|
|
||||||
it('should use correct colors for different banner types', () => {
|
|
||||||
const types = [
|
|
||||||
{type: 'success', expectedColor: '#28a745'},
|
|
||||||
{type: 'warning', expectedColor: '#ffc107'},
|
|
||||||
{type: 'error', expectedColor: '#d24b47'},
|
|
||||||
{type: 'info', expectedColor: '#0066cc'},
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
types.forEach(({type, expectedColor}) => {
|
|
||||||
const banner = createMockBanner({type});
|
|
||||||
const {unmount} = renderBannerItem(banner);
|
|
||||||
|
|
||||||
expect(screen.getByText(new RegExp(`Color: ${expectedColor}`))).toBeTruthy();
|
|
||||||
unmount();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('accessibility', () => {
|
|
||||||
it('should be pressable when handlers are provided', () => {
|
|
||||||
const banner = createMockBanner();
|
|
||||||
renderBannerItem(banner, {onPress: mockOnPress});
|
|
||||||
|
|
||||||
const bannerContainer = screen.getByTestId('banner-item-test-banner-1');
|
|
||||||
|
|
||||||
expect(bannerContainer.props.disabled).toBeFalsy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not respond to press when no press handlers are provided', () => {
|
|
||||||
const banner = createMockBanner({onPress: undefined});
|
|
||||||
renderBannerItem(banner);
|
|
||||||
|
|
||||||
const bannerContainer = screen.getByTestId('banner-item-test-banner-1');
|
|
||||||
fireEvent.press(bannerContainer);
|
|
||||||
|
|
||||||
// Should not respond to press
|
|
||||||
expect(mockBannerOnPress).not.toHaveBeenCalled();
|
|
||||||
expect(mockOnPress).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('press event handling', () => {
|
|
||||||
it('should handle pressIn without triggering onPress', () => {
|
|
||||||
const banner = createMockBanner();
|
|
||||||
renderBannerItem(banner, {onPress: mockOnPress});
|
|
||||||
|
|
||||||
const bannerContainer = screen.getByTestId('banner-item-test-banner-1');
|
|
||||||
|
|
||||||
fireEvent(bannerContainer, 'pressIn');
|
|
||||||
|
|
||||||
expect(bannerContainer).toBeTruthy();
|
|
||||||
expect(mockOnPress).not.toHaveBeenCalled(); // pressIn shouldn't trigger onPress
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle pressIn on disabled banner', () => {
|
|
||||||
const banner = createMockBanner({onPress: undefined});
|
|
||||||
renderBannerItem(banner);
|
|
||||||
|
|
||||||
const bannerContainer = screen.getByTestId('banner-item-test-banner-1');
|
|
||||||
|
|
||||||
fireEvent(bannerContainer, 'pressIn');
|
|
||||||
|
|
||||||
expect(bannerContainer).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle dismiss button press events', () => {
|
|
||||||
const banner = createMockBanner();
|
|
||||||
renderBannerItem(banner, {onDismiss: mockOnDismiss});
|
|
||||||
|
|
||||||
const dismissButton = screen.getByTestId('banner-dismiss-test-banner-1');
|
|
||||||
|
|
||||||
fireEvent(dismissButton, 'pressIn');
|
|
||||||
fireEvent(dismissButton, 'pressOut');
|
|
||||||
|
|
||||||
expect(dismissButton).toBeTruthy();
|
|
||||||
expect(mockOnDismiss).not.toHaveBeenCalled(); // pressIn/pressOut shouldn't trigger onDismiss
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('edge cases', () => {
|
|
||||||
it('should handle empty title and message gracefully', () => {
|
|
||||||
const banner = createMockBanner({title: '', message: ''});
|
|
||||||
renderBannerItem(banner);
|
|
||||||
|
|
||||||
// Should render without crashing - check for icon presence
|
|
||||||
expect(screen.getByTestId('compass-icon')).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle undefined title and message gracefully', () => {
|
|
||||||
const banner = createMockBanner({title: undefined, message: undefined});
|
|
||||||
renderBannerItem(banner);
|
|
||||||
|
|
||||||
expect(screen.getByTestId('compass-icon')).toBeTruthy();
|
|
||||||
const contentContainer = screen.getByTestId('banner-content-test-banner-1');
|
|
||||||
expect(contentContainer.children).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle very long title and message', () => {
|
|
||||||
const longTitle = 'Very Long Title '.repeat(50);
|
|
||||||
const longMessage = 'Very Long Message '.repeat(50);
|
|
||||||
const banner = createMockBanner({
|
|
||||||
title: longTitle,
|
|
||||||
message: longMessage,
|
|
||||||
});
|
|
||||||
renderBannerItem(banner);
|
|
||||||
|
|
||||||
expect(screen.getByText(longTitle)).toBeTruthy();
|
|
||||||
expect(screen.getByText(longMessage)).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle special characters in title and message', () => {
|
|
||||||
const banner = createMockBanner({
|
|
||||||
title: 'Title with émojis 🎉 and spéciäl chars',
|
|
||||||
message: 'Message with <html> & "quotes" and more émojis 🚀',
|
|
||||||
});
|
|
||||||
renderBannerItem(banner);
|
|
||||||
|
|
||||||
expect(screen.getByText('Title with émojis 🎉 and spéciäl chars')).toBeTruthy();
|
|
||||||
expect(screen.getByText('Message with <html> & "quotes" and more émojis 🚀')).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle undefined banner type gracefully', () => {
|
|
||||||
const banner = createMockBanner({type: undefined});
|
|
||||||
renderBannerItem(banner);
|
|
||||||
|
|
||||||
// Should default to info icon and color
|
|
||||||
expect(screen.getByText(/Icon: information/)).toBeTruthy();
|
|
||||||
expect(screen.getByText(/Color: #0066cc/)).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,188 +0,0 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
||||||
// See LICENSE.txt for license information.
|
|
||||||
|
|
||||||
import React, {useCallback} from 'react';
|
|
||||||
import {View, Text, Pressable, StyleSheet} from 'react-native';
|
|
||||||
|
|
||||||
import CompassIcon from '@components/compass_icon';
|
|
||||||
import {useTheme} from '@context/theme';
|
|
||||||
import {changeOpacity} from '@utils/theme';
|
|
||||||
import {typography} from '@utils/typography';
|
|
||||||
|
|
||||||
export interface BannerItemConfig {
|
|
||||||
id: string;
|
|
||||||
title?: string;
|
|
||||||
message?: string;
|
|
||||||
type?: 'info' | 'success' | 'warning' | 'error';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the banner can be dismissed
|
|
||||||
* @default true
|
|
||||||
*/
|
|
||||||
dismissible?: boolean;
|
|
||||||
onPress?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BannerItemProps {
|
|
||||||
banner: BannerItemConfig;
|
|
||||||
onPress?: (banner: BannerItemConfig) => void;
|
|
||||||
onDismiss?: (banner: BannerItemConfig) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const getStyleSheet = (theme: Theme) => StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
backgroundColor: theme.centerChannelBg,
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: 8,
|
|
||||||
marginHorizontal: 8,
|
|
||||||
marginVertical: 4,
|
|
||||||
height: 40,
|
|
||||||
shadowColor: theme.centerChannelColor,
|
|
||||||
shadowOffset: {
|
|
||||||
width: 0,
|
|
||||||
height: 2,
|
|
||||||
},
|
|
||||||
shadowOpacity: 0.1,
|
|
||||||
shadowRadius: 4,
|
|
||||||
elevation: 4,
|
|
||||||
},
|
|
||||||
iconContainer: {
|
|
||||||
marginRight: 8,
|
|
||||||
width: 20,
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
content: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
...typography('Body', 75, 'SemiBold'),
|
|
||||||
color: theme.centerChannelColor,
|
|
||||||
marginBottom: 1,
|
|
||||||
},
|
|
||||||
message: {
|
|
||||||
...typography('Body', 50),
|
|
||||||
color: changeOpacity(theme.centerChannelColor, 0.7),
|
|
||||||
},
|
|
||||||
dismissButton: {
|
|
||||||
marginLeft: 12,
|
|
||||||
padding: 4,
|
|
||||||
},
|
|
||||||
pressed: {
|
|
||||||
opacity: 0.7,
|
|
||||||
},
|
|
||||||
dismissPressed: {
|
|
||||||
opacity: 0.5,
|
|
||||||
},
|
|
||||||
info: {
|
|
||||||
borderLeftWidth: 4,
|
|
||||||
borderLeftColor: theme.linkColor,
|
|
||||||
},
|
|
||||||
success: {
|
|
||||||
borderLeftWidth: 4,
|
|
||||||
borderLeftColor: '#28a745',
|
|
||||||
},
|
|
||||||
warning: {
|
|
||||||
borderLeftWidth: 4,
|
|
||||||
borderLeftColor: '#ffc107',
|
|
||||||
},
|
|
||||||
error: {
|
|
||||||
borderLeftWidth: 4,
|
|
||||||
borderLeftColor: theme.errorTextColor,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const getBannerIconName = (type: string): string => {
|
|
||||||
switch (type) {
|
|
||||||
case 'success':
|
|
||||||
return 'check-circle';
|
|
||||||
case 'warning':
|
|
||||||
return 'alert';
|
|
||||||
case 'error':
|
|
||||||
return 'alert-circle';
|
|
||||||
default:
|
|
||||||
return 'information';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getBannerIconColor = (type: string, theme: Theme): string => {
|
|
||||||
switch (type) {
|
|
||||||
case 'success':
|
|
||||||
return '#28a745';
|
|
||||||
case 'warning':
|
|
||||||
return '#ffc107';
|
|
||||||
case 'error':
|
|
||||||
return theme.errorTextColor;
|
|
||||||
default:
|
|
||||||
return theme.linkColor;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const BannerItem: React.FC<BannerItemProps> = ({banner, onPress, onDismiss}) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const styles = getStyleSheet(theme);
|
|
||||||
|
|
||||||
const handlePress = useCallback(() => {
|
|
||||||
if (banner.onPress) {
|
|
||||||
banner.onPress();
|
|
||||||
}
|
|
||||||
if (onPress) {
|
|
||||||
onPress(banner);
|
|
||||||
}
|
|
||||||
}, [banner, onPress]);
|
|
||||||
|
|
||||||
const handleDismiss = useCallback(() => {
|
|
||||||
if (onDismiss) {
|
|
||||||
onDismiss(banner);
|
|
||||||
}
|
|
||||||
}, [banner, onDismiss]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Pressable
|
|
||||||
testID={`banner-item-${banner.id}`}
|
|
||||||
style={({pressed}) => [
|
|
||||||
styles.container,
|
|
||||||
styles[banner.type || 'info'],
|
|
||||||
pressed && (banner.onPress || onPress) && styles.pressed,
|
|
||||||
]}
|
|
||||||
onPress={handlePress}
|
|
||||||
disabled={!banner.onPress && !onPress}
|
|
||||||
>
|
|
||||||
<View style={styles.iconContainer}>
|
|
||||||
<CompassIcon
|
|
||||||
name={getBannerIconName(banner.type || 'info')}
|
|
||||||
size={16}
|
|
||||||
color={getBannerIconColor(banner.type || 'info', theme)}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View
|
|
||||||
style={styles.content}
|
|
||||||
testID={`banner-content-${banner.id}`}
|
|
||||||
>
|
|
||||||
{banner.title && <Text style={styles.title}>{banner.title}</Text>}
|
|
||||||
{banner.message && <Text style={styles.message}>{banner.message}</Text>}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{banner.dismissible !== false && onDismiss && (
|
|
||||||
<Pressable
|
|
||||||
testID={`banner-dismiss-${banner.id}`}
|
|
||||||
style={({pressed}) => [
|
|
||||||
styles.dismissButton,
|
|
||||||
pressed && styles.dismissPressed,
|
|
||||||
]}
|
|
||||||
onPress={handleDismiss}
|
|
||||||
>
|
|
||||||
<CompassIcon
|
|
||||||
name={'close'}
|
|
||||||
size={14}
|
|
||||||
color={changeOpacity(theme.centerChannelColor, 0.5)}
|
|
||||||
/>
|
|
||||||
</Pressable>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default BannerItem;
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
||||||
// See LICENSE.txt for license information.
|
|
||||||
|
|
||||||
import {renderHook} from '@testing-library/react-native';
|
|
||||||
|
|
||||||
import {useBanner} from './useBanner';
|
|
||||||
|
|
||||||
jest.mock('react-native-reanimated', () => {
|
|
||||||
const actualReanimated = jest.requireActual('react-native-reanimated/mock');
|
|
||||||
return {
|
|
||||||
...actualReanimated,
|
|
||||||
useSharedValue: jest.fn((initial) => ({value: initial})),
|
|
||||||
useAnimatedStyle: jest.fn((callback) => callback()),
|
|
||||||
withTiming: jest.fn((value) => value),
|
|
||||||
runOnJS: jest.fn((fn) => fn),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
jest.mock('react-native-gesture-handler', () => ({
|
|
||||||
Gesture: {
|
|
||||||
Pan: jest.fn(() => ({
|
|
||||||
onStart: jest.fn().mockReturnThis(),
|
|
||||||
onUpdate: jest.fn().mockReturnThis(),
|
|
||||||
onEnd: jest.fn().mockReturnThis(),
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('useBanner', () => {
|
|
||||||
const defaultProps = {
|
|
||||||
animationDuration: 250,
|
|
||||||
dismissible: true,
|
|
||||||
swipeThreshold: 100,
|
|
||||||
onDismiss: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns animatedStyle and swipeGesture', () => {
|
|
||||||
const {result} = renderHook(() => useBanner(defaultProps));
|
|
||||||
|
|
||||||
expect(result.current).toHaveProperty('animatedStyle');
|
|
||||||
expect(result.current).toHaveProperty('swipeGesture');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('initializes with visible state', () => {
|
|
||||||
const {result} = renderHook(() => useBanner(defaultProps));
|
|
||||||
|
|
||||||
expect(result.current.animatedStyle).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('creates gesture when dismissible is true', () => {
|
|
||||||
const {result} = renderHook(() => useBanner({
|
|
||||||
...defaultProps,
|
|
||||||
dismissible: true,
|
|
||||||
}));
|
|
||||||
|
|
||||||
expect(result.current.swipeGesture).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('creates gesture when dismissible is false', () => {
|
|
||||||
const {result} = renderHook(() => useBanner({
|
|
||||||
...defaultProps,
|
|
||||||
dismissible: false,
|
|
||||||
}));
|
|
||||||
|
|
||||||
expect(result.current.swipeGesture).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
||||||
// See LICENSE.txt for license information.
|
|
||||||
|
|
||||||
import {Gesture} from 'react-native-gesture-handler';
|
|
||||||
import {runOnJS, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
|
|
||||||
|
|
||||||
const SWIPE_DISMISS_ANIMATION_DURATION = 200;
|
|
||||||
|
|
||||||
interface UseBannerProps {
|
|
||||||
animationDuration: number;
|
|
||||||
dismissible: boolean;
|
|
||||||
swipeThreshold: number;
|
|
||||||
onDismiss?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useBanner = ({
|
|
||||||
animationDuration,
|
|
||||||
dismissible,
|
|
||||||
swipeThreshold,
|
|
||||||
onDismiss,
|
|
||||||
}: UseBannerProps) => {
|
|
||||||
const opacity = useSharedValue(1);
|
|
||||||
const translateY = useSharedValue(0);
|
|
||||||
const translateX = useSharedValue(0);
|
|
||||||
const isDismissed = useSharedValue(false);
|
|
||||||
|
|
||||||
const animatedStyle = useAnimatedStyle(() => ({
|
|
||||||
opacity: withTiming(opacity.value, {duration: animationDuration}),
|
|
||||||
transform: [
|
|
||||||
{
|
|
||||||
translateY: withTiming(translateY.value, {duration: animationDuration}),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
translateX: translateX.value,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}));
|
|
||||||
|
|
||||||
const startX = useSharedValue(0);
|
|
||||||
|
|
||||||
const swipeGesture = Gesture.Pan().
|
|
||||||
onStart(() => {
|
|
||||||
startX.value = translateX.value;
|
|
||||||
}).
|
|
||||||
onUpdate((event) => {
|
|
||||||
if (!dismissible) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
translateX.value = startX.value + event.translationX;
|
|
||||||
}).
|
|
||||||
onEnd(() => {
|
|
||||||
if (!dismissible) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const shouldDismiss = Math.abs(translateX.value) > swipeThreshold;
|
|
||||||
|
|
||||||
if (shouldDismiss && !isDismissed.value) {
|
|
||||||
isDismissed.value = true;
|
|
||||||
translateX.value = withTiming(
|
|
||||||
translateX.value > 0 ? 300 : -300,
|
|
||||||
{duration: SWIPE_DISMISS_ANIMATION_DURATION},
|
|
||||||
);
|
|
||||||
opacity.value = withTiming(0, {duration: SWIPE_DISMISS_ANIMATION_DURATION});
|
|
||||||
|
|
||||||
if (onDismiss) {
|
|
||||||
runOnJS(onDismiss)();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
translateX.value = withTiming(0, {duration: SWIPE_DISMISS_ANIMATION_DURATION});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
animatedStyle,
|
|
||||||
swipeGesture,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
||||||
// See LICENSE.txt for license information.
|
|
||||||
|
|
||||||
import type {StyleProp, ViewStyle} from 'react-native';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Props for the Banner component
|
|
||||||
*
|
|
||||||
* A flexible banner component that positions itself absolutely on screen
|
|
||||||
* with smart offset calculations based on existing UI elements.
|
|
||||||
*/
|
|
||||||
export interface BannerProps {
|
|
||||||
|
|
||||||
/** The content to display inside the banner */
|
|
||||||
children: React.ReactNode;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Duration of fade animation in milliseconds
|
|
||||||
* @default 300
|
|
||||||
*/
|
|
||||||
animationDuration?: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom styles applied to the banner content
|
|
||||||
*/
|
|
||||||
style?: StyleProp<ViewStyle>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the banner can be dismissed by swiping
|
|
||||||
* @default false
|
|
||||||
*/
|
|
||||||
dismissible?: boolean;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Callback called when banner is dismissed via swipe
|
|
||||||
*/
|
|
||||||
onDismiss?: () => void;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Swipe threshold to trigger dismiss (in pixels)
|
|
||||||
* @default 100
|
|
||||||
*/
|
|
||||||
swipeThreshold?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,112 +1,202 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import React from 'react';
|
import {useNetInfo} from '@react-native-community/netinfo';
|
||||||
import {Text, View, Pressable} from 'react-native';
|
import React, {useCallback, useEffect, useRef, useState} from 'react';
|
||||||
|
import {useIntl} from 'react-intl';
|
||||||
|
import {
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
} from 'react-native';
|
||||||
|
import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
|
||||||
|
|
||||||
import CompassIcon from '@components/compass_icon';
|
import CompassIcon from '@components/compass_icon';
|
||||||
|
import {ANNOUNCEMENT_BAR_HEIGHT} from '@constants/view';
|
||||||
import {useTheme} from '@context/theme';
|
import {useTheme} from '@context/theme';
|
||||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
import {useAppState} from '@hooks/device';
|
||||||
|
import useDidUpdate from '@hooks/did_update';
|
||||||
|
import {toMilliseconds} from '@utils/datetime';
|
||||||
|
import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||||
import {typography} from '@utils/typography';
|
import {typography} from '@utils/typography';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
isConnected: boolean;
|
websocketState: WebsocketConnectedState;
|
||||||
message: string;
|
|
||||||
dismissible?: boolean;
|
|
||||||
onDismiss?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStyle = makeStyleSheetFromTheme((theme: Theme) => {
|
const getStyle = makeStyleSheetFromTheme((theme: Theme) => {
|
||||||
|
const bannerContainer = {
|
||||||
|
flex: 1,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
overflow: 'hidden' as const,
|
||||||
|
flexDirection: 'row' as const,
|
||||||
|
alignItems: 'center' as const,
|
||||||
|
marginHorizontal: 8,
|
||||||
|
borderRadius: 7,
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
container: {
|
background: {
|
||||||
flexDirection: 'row' as const,
|
backgroundColor: theme.sidebarBg,
|
||||||
alignItems: 'center' as const,
|
zIndex: 1,
|
||||||
paddingHorizontal: 12,
|
|
||||||
paddingVertical: 6,
|
|
||||||
borderRadius: 8,
|
|
||||||
height: 40,
|
|
||||||
shadowColor: theme.centerChannelColor,
|
|
||||||
shadowOffset: {
|
|
||||||
width: 0,
|
|
||||||
height: 2,
|
|
||||||
},
|
|
||||||
shadowOpacity: 0.1,
|
|
||||||
shadowRadius: 4,
|
|
||||||
elevation: 4,
|
|
||||||
},
|
},
|
||||||
containerNotConnected: {
|
bannerContainerNotConnected: {
|
||||||
|
...bannerContainer,
|
||||||
backgroundColor: theme.centerChannelColor,
|
backgroundColor: theme.centerChannelColor,
|
||||||
},
|
},
|
||||||
containerConnected: {
|
bannerContainerConnected: {
|
||||||
|
...bannerContainer,
|
||||||
backgroundColor: theme.onlineIndicator,
|
backgroundColor: theme.onlineIndicator,
|
||||||
},
|
},
|
||||||
content: {
|
wrapper: {
|
||||||
|
flexDirection: 'row',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
flexDirection: 'row' as const,
|
overflow: 'hidden',
|
||||||
alignItems: 'center' as const,
|
|
||||||
justifyContent: 'center' as const,
|
|
||||||
},
|
},
|
||||||
text: {
|
bannerTextContainer: {
|
||||||
...typography('Body', 100, 'SemiBold'),
|
flex: 1,
|
||||||
|
flexGrow: 1,
|
||||||
|
marginRight: 5,
|
||||||
|
textAlign: 'center',
|
||||||
color: theme.centerChannelBg,
|
color: theme.centerChannelBg,
|
||||||
textAlign: 'center' as const,
|
|
||||||
},
|
},
|
||||||
icon: {
|
bannerText: {
|
||||||
marginRight: 8,
|
...typography('Body', 100, 'SemiBold'),
|
||||||
},
|
|
||||||
dismissButton: {
|
|
||||||
marginLeft: 8,
|
|
||||||
padding: 4,
|
|
||||||
},
|
|
||||||
dismissPressed: {
|
|
||||||
opacity: 0.5,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const ConnectionBanner: React.FC<Props> = ({isConnected, message, dismissible = false, onDismiss}) => {
|
const clearTimeoutRef = (ref: React.MutableRefObject<NodeJS.Timeout | null | undefined>) => {
|
||||||
|
if (ref.current) {
|
||||||
|
clearTimeout(ref.current);
|
||||||
|
ref.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const TIME_TO_OPEN = toMilliseconds({seconds: 3});
|
||||||
|
const TIME_TO_CLOSE = toMilliseconds({seconds: 1});
|
||||||
|
|
||||||
|
const ConnectionBanner = ({
|
||||||
|
websocketState,
|
||||||
|
}: Props) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const closeTimeout = useRef<NodeJS.Timeout | null>();
|
||||||
|
const openTimeout = useRef<NodeJS.Timeout | null>();
|
||||||
|
const height = useSharedValue(0);
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
const style = getStyle(theme);
|
const style = getStyle(theme);
|
||||||
|
const appState = useAppState();
|
||||||
|
const netInfo = useNetInfo();
|
||||||
|
|
||||||
|
const isConnected = websocketState === 'connected';
|
||||||
|
|
||||||
|
const openCallback = useCallback(() => {
|
||||||
|
setVisible(true);
|
||||||
|
clearTimeoutRef(openTimeout);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeCallback = useCallback(() => {
|
||||||
|
setVisible(false);
|
||||||
|
clearTimeoutRef(closeTimeout);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (websocketState === 'connecting') {
|
||||||
|
openCallback();
|
||||||
|
} else if (!isConnected) {
|
||||||
|
openTimeout.current = setTimeout(openCallback, TIME_TO_OPEN);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
clearTimeoutRef(openTimeout);
|
||||||
|
clearTimeoutRef(closeTimeout);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useDidUpdate(() => {
|
||||||
|
if (isConnected) {
|
||||||
|
if (visible) {
|
||||||
|
if (!closeTimeout.current) {
|
||||||
|
closeTimeout.current = setTimeout(closeCallback, TIME_TO_CLOSE);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
clearTimeoutRef(openTimeout);
|
||||||
|
}
|
||||||
|
} else if (visible) {
|
||||||
|
clearTimeoutRef(closeTimeout);
|
||||||
|
} else if (appState === 'active') {
|
||||||
|
setVisible(true);
|
||||||
|
}
|
||||||
|
}, [isConnected]);
|
||||||
|
|
||||||
|
useDidUpdate(() => {
|
||||||
|
if (appState === 'active') {
|
||||||
|
if (!isConnected && !visible) {
|
||||||
|
if (!openTimeout.current) {
|
||||||
|
openTimeout.current = setTimeout(openCallback, TIME_TO_OPEN);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isConnected && visible) {
|
||||||
|
if (!closeTimeout.current) {
|
||||||
|
closeTimeout.current = setTimeout(closeCallback, TIME_TO_CLOSE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setVisible(false);
|
||||||
|
clearTimeoutRef(openTimeout);
|
||||||
|
clearTimeoutRef(closeTimeout);
|
||||||
|
}
|
||||||
|
}, [appState === 'active']);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
height.value = withTiming(visible ? ANNOUNCEMENT_BAR_HEIGHT : 0, {
|
||||||
|
duration: 200,
|
||||||
|
});
|
||||||
|
}, [height, visible]);
|
||||||
|
|
||||||
|
const bannerStyle = useAnimatedStyle(() => ({
|
||||||
|
height: height.value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
let text;
|
||||||
|
if (isConnected) {
|
||||||
|
text = intl.formatMessage({id: 'connection_banner.connected', defaultMessage: 'Connection restored'});
|
||||||
|
} else if (websocketState === 'connecting') {
|
||||||
|
text = intl.formatMessage({id: 'connection_banner.connecting', defaultMessage: 'Connecting...'});
|
||||||
|
} else if (netInfo.isInternetReachable) {
|
||||||
|
text = intl.formatMessage({id: 'connection_banner.not_reachable', defaultMessage: 'The server is not reachable'});
|
||||||
|
} else {
|
||||||
|
text = intl.formatMessage({id: 'connection_banner.not_connected', defaultMessage: 'No internet connection'});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<Animated.View
|
||||||
style={[
|
style={[style.background, bannerStyle]}
|
||||||
style.container,
|
|
||||||
isConnected ? style.containerConnected : style.containerNotConnected,
|
|
||||||
]}
|
|
||||||
>
|
>
|
||||||
<View style={style.content}>
|
<View
|
||||||
<CompassIcon
|
style={isConnected ? style.bannerContainerConnected : style.bannerContainerNotConnected}
|
||||||
color={theme.centerChannelBg}
|
>
|
||||||
name={isConnected ? 'check' : 'information-outline'}
|
{visible &&
|
||||||
size={16}
|
<View
|
||||||
style={style.icon}
|
style={style.wrapper}
|
||||||
/>
|
|
||||||
<Text
|
|
||||||
style={style.text}
|
|
||||||
ellipsizeMode='tail'
|
|
||||||
numberOfLines={1}
|
|
||||||
>
|
>
|
||||||
{message}
|
<Text
|
||||||
</Text>
|
style={style.bannerTextContainer}
|
||||||
|
ellipsizeMode='tail'
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
<CompassIcon
|
||||||
|
color={theme.centerChannelBg}
|
||||||
|
name={isConnected ? 'check' : 'information-outline'}
|
||||||
|
size={18}
|
||||||
|
/>
|
||||||
|
{' '}
|
||||||
|
<Text style={style.bannerText}>
|
||||||
|
{text}
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
}
|
||||||
</View>
|
</View>
|
||||||
|
</Animated.View>
|
||||||
{dismissible && onDismiss && (
|
|
||||||
<Pressable
|
|
||||||
style={({pressed}) => [
|
|
||||||
style.dismissButton,
|
|
||||||
pressed && style.dismissPressed,
|
|
||||||
]}
|
|
||||||
onPress={onDismiss}
|
|
||||||
>
|
|
||||||
<CompassIcon
|
|
||||||
name={'close'}
|
|
||||||
size={14}
|
|
||||||
color={changeOpacity(theme.centerChannelBg, 0.7)}
|
|
||||||
/>
|
|
||||||
</Pressable>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
15
app/components/connection_banner/index.ts
Normal file
15
app/components/connection_banner/index.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import {withObservables} from '@nozbe/watermelondb/react';
|
||||||
|
|
||||||
|
import {withServerUrl} from '@context/server';
|
||||||
|
import WebsocketManager from '@managers/websocket_manager';
|
||||||
|
|
||||||
|
import ConnectionBanner from './connection_banner';
|
||||||
|
|
||||||
|
const enhanced = withObservables(['serverUrl'], ({serverUrl}: {serverUrl: string}) => ({
|
||||||
|
websocketState: WebsocketManager.observeWebsocketState(serverUrl),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default withServerUrl(enhanced(ConnectionBanner));
|
||||||
|
|
@ -1,257 +0,0 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
||||||
// See LICENSE.txt for license information.
|
|
||||||
|
|
||||||
import {render, fireEvent, screen} from '@testing-library/react-native';
|
|
||||||
import React from 'react';
|
|
||||||
import {Text} from 'react-native';
|
|
||||||
|
|
||||||
import AnimatedBannerItem from './animated_banner_item';
|
|
||||||
|
|
||||||
import type {FloatingBannerConfig} from './types';
|
|
||||||
|
|
||||||
jest.mock('react-native-reanimated', () => {
|
|
||||||
const {View} = require('react-native');
|
|
||||||
const ReactLib = require('react');
|
|
||||||
|
|
||||||
const AnimatedView = ReactLib.forwardRef((props: Record<string, unknown>, ref: unknown) => {
|
|
||||||
return ReactLib.createElement(View, {...props, ref});
|
|
||||||
});
|
|
||||||
AnimatedView.displayName = 'AnimatedView';
|
|
||||||
|
|
||||||
return {
|
|
||||||
__esModule: true,
|
|
||||||
useAnimatedStyle: (fn: () => unknown) => fn(),
|
|
||||||
useSharedValue: (initial: unknown) => ({value: initial}),
|
|
||||||
withTiming: (value: unknown) => value,
|
|
||||||
withDelay: (_delay: number, value: unknown) => value,
|
|
||||||
default: {
|
|
||||||
View: AnimatedView,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
jest.mock('@components/banner/Banner', () => {
|
|
||||||
const mockView = require('react-native').View;
|
|
||||||
const mockText = require('react-native').Text;
|
|
||||||
const mockPressable = require('react-native').Pressable;
|
|
||||||
const mockReact = require('react');
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
return function MockBanner({children, onDismiss, ...props}: {children?: any; onDismiss?: any; [key: string]: any}) {
|
|
||||||
return mockReact.createElement(
|
|
||||||
mockView,
|
|
||||||
{testID: 'banner', ...props},
|
|
||||||
children,
|
|
||||||
onDismiss && mockReact.createElement(
|
|
||||||
mockPressable,
|
|
||||||
{testID: 'banner-dismiss', onPress: onDismiss},
|
|
||||||
mockReact.createElement(mockText, null, 'Dismiss'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
jest.mock('@components/banner/banner_item', () => {
|
|
||||||
const mockView = require('react-native').View;
|
|
||||||
const mockText = require('react-native').Text;
|
|
||||||
const mockPressable = require('react-native').Pressable;
|
|
||||||
const mockReact = require('react');
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
return function MockBannerItem({banner, onPress, onDismiss}: {banner: any; onPress?: any; onDismiss?: any}) {
|
|
||||||
return mockReact.createElement(
|
|
||||||
mockView,
|
|
||||||
{testID: 'banner-item', 'data-banner-id': banner.id},
|
|
||||||
mockReact.createElement(mockText, null, banner.title),
|
|
||||||
mockReact.createElement(mockText, null, banner.message),
|
|
||||||
onPress && mockReact.createElement(
|
|
||||||
mockPressable,
|
|
||||||
{testID: 'banner-item-press', onPress: () => onPress(banner)},
|
|
||||||
mockReact.createElement(mockText, null, 'Press'),
|
|
||||||
),
|
|
||||||
onDismiss && mockReact.createElement(
|
|
||||||
mockPressable,
|
|
||||||
{testID: 'banner-item-dismiss', onPress: () => onDismiss(banner)},
|
|
||||||
mockReact.createElement(mockText, null, 'Dismiss'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
jest.mock('@hooks/header', () => ({
|
|
||||||
useDefaultHeaderHeight: jest.fn(() => 64),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('AnimatedBannerItem', () => {
|
|
||||||
const mockOnBannerPress = jest.fn();
|
|
||||||
const mockOnBannerDismiss = jest.fn();
|
|
||||||
|
|
||||||
const createMockBanner = (overrides: Partial<FloatingBannerConfig> = {}): FloatingBannerConfig => ({
|
|
||||||
id: 'test-banner-1',
|
|
||||||
title: 'Test Banner',
|
|
||||||
message: 'This is a test message',
|
|
||||||
type: 'info',
|
|
||||||
dismissible: true,
|
|
||||||
...overrides,
|
|
||||||
});
|
|
||||||
|
|
||||||
const renderAnimatedBannerItem = (
|
|
||||||
banner: FloatingBannerConfig,
|
|
||||||
index: number = 0,
|
|
||||||
) => {
|
|
||||||
return render(
|
|
||||||
<AnimatedBannerItem
|
|
||||||
banner={banner}
|
|
||||||
index={index}
|
|
||||||
onBannerPress={mockOnBannerPress}
|
|
||||||
onBannerDismiss={mockOnBannerDismiss}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('rendering', () => {
|
|
||||||
it('should render banner with correct props', () => {
|
|
||||||
const banner = createMockBanner();
|
|
||||||
renderAnimatedBannerItem(banner);
|
|
||||||
|
|
||||||
const bannerElement = screen.getByTestId('banner');
|
|
||||||
expect(bannerElement.props.dismissible).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render BannerItem when no custom component is provided', () => {
|
|
||||||
const banner = createMockBanner();
|
|
||||||
renderAnimatedBannerItem(banner);
|
|
||||||
|
|
||||||
const bannerItem = screen.getByTestId('banner-item');
|
|
||||||
expect(bannerItem.props['data-banner-id']).toBe('test-banner-1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render custom component when provided', () => {
|
|
||||||
const customComponent = <Text testID={'custom-content'}>{'Custom Banner Content'}</Text>;
|
|
||||||
const banner = createMockBanner({customComponent});
|
|
||||||
renderAnimatedBannerItem(banner);
|
|
||||||
|
|
||||||
expect(screen.getByTestId('custom-content')).toBeDefined();
|
|
||||||
expect(screen.queryByTestId('banner-item')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle non-dismissible banners', () => {
|
|
||||||
const banner = createMockBanner({dismissible: false});
|
|
||||||
renderAnimatedBannerItem(banner);
|
|
||||||
|
|
||||||
const bannerElement = screen.getByTestId('banner');
|
|
||||||
expect(bannerElement.props.dismissible).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle banner with dismissible undefined (defaults to true)', () => {
|
|
||||||
const banner = createMockBanner();
|
|
||||||
delete banner.dismissible;
|
|
||||||
renderAnimatedBannerItem(banner);
|
|
||||||
|
|
||||||
const bannerElement = screen.getByTestId('banner');
|
|
||||||
expect(bannerElement.props.dismissible).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render BannerItem when customComponent is falsy', () => {
|
|
||||||
const banner = createMockBanner({customComponent: null});
|
|
||||||
renderAnimatedBannerItem(banner);
|
|
||||||
|
|
||||||
const bannerItem = screen.getByTestId('banner-item');
|
|
||||||
expect(bannerItem.props['data-banner-id']).toBe('test-banner-1');
|
|
||||||
expect(screen.queryByTestId('custom-content')).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('event handlers', () => {
|
|
||||||
it('should call onBannerPress when banner item is pressed', () => {
|
|
||||||
const banner = createMockBanner({onPress: jest.fn()});
|
|
||||||
renderAnimatedBannerItem(banner);
|
|
||||||
|
|
||||||
const pressButton = screen.getByTestId('banner-item-press');
|
|
||||||
fireEvent.press(pressButton);
|
|
||||||
|
|
||||||
expect(mockOnBannerPress).toHaveBeenCalledWith(banner);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call onBannerDismiss when banner item dismiss is pressed', () => {
|
|
||||||
const banner = createMockBanner();
|
|
||||||
renderAnimatedBannerItem(banner);
|
|
||||||
|
|
||||||
const dismissButton = screen.getByTestId('banner-item-dismiss');
|
|
||||||
fireEvent.press(dismissButton);
|
|
||||||
|
|
||||||
expect(mockOnBannerDismiss).toHaveBeenCalledWith(banner);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call onBannerDismiss when Banner dismiss is pressed', () => {
|
|
||||||
const banner = createMockBanner();
|
|
||||||
renderAnimatedBannerItem(banner);
|
|
||||||
|
|
||||||
const bannerDismissButton = screen.getByTestId('banner-dismiss');
|
|
||||||
fireEvent.press(bannerDismissButton);
|
|
||||||
|
|
||||||
expect(mockOnBannerDismiss).toHaveBeenCalledWith(banner);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('positioning', () => {
|
|
||||||
it('should render for top position', () => {
|
|
||||||
const banner = createMockBanner();
|
|
||||||
renderAnimatedBannerItem(banner, 0);
|
|
||||||
|
|
||||||
expect(screen.getByTestId('banner')).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render for bottom position', () => {
|
|
||||||
const banner = createMockBanner();
|
|
||||||
renderAnimatedBannerItem(banner, 0);
|
|
||||||
|
|
||||||
expect(screen.getByTestId('banner')).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render on tablet with top position', () => {
|
|
||||||
const banner = createMockBanner();
|
|
||||||
renderAnimatedBannerItem(banner, 0);
|
|
||||||
|
|
||||||
expect(screen.getByTestId('banner')).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render on tablet with bottom position', () => {
|
|
||||||
const banner = createMockBanner();
|
|
||||||
renderAnimatedBannerItem(banner, 0);
|
|
||||||
|
|
||||||
expect(screen.getByTestId('banner')).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render at different index positions', () => {
|
|
||||||
const banner = createMockBanner();
|
|
||||||
renderAnimatedBannerItem(banner, 2);
|
|
||||||
|
|
||||||
expect(screen.getByTestId('banner')).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('custom content', () => {
|
|
||||||
it('should render custom content for a bottom-positioned banner', () => {
|
|
||||||
const customComponent = <Text testID={'custom-content-bottom'}>{'Bottom Custom'}</Text>;
|
|
||||||
const banner = createMockBanner({customComponent, dismissible: false});
|
|
||||||
renderAnimatedBannerItem(banner, 0);
|
|
||||||
|
|
||||||
expect(screen.getByTestId('custom-content-bottom')).toBeDefined();
|
|
||||||
const bannerElement = screen.getByTestId('banner');
|
|
||||||
expect(bannerElement.props.dismissible).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render custom content without title and message', () => {
|
|
||||||
const customComponent = <Text testID={'custom-no-text'}>{'Custom Banner'}</Text>;
|
|
||||||
const banner = createMockBanner({title: undefined, message: undefined, customComponent});
|
|
||||||
renderAnimatedBannerItem(banner);
|
|
||||||
|
|
||||||
expect(screen.getByTestId('custom-no-text')).toBeDefined();
|
|
||||||
expect(screen.queryByTestId('banner-item')).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
||||||
// See LICENSE.txt for license information.
|
|
||||||
|
|
||||||
import React, {useCallback, useEffect, useRef} from 'react';
|
|
||||||
import {StyleSheet, View} from 'react-native';
|
|
||||||
import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
|
|
||||||
|
|
||||||
import Banner from '@components/banner/Banner';
|
|
||||||
import BannerItem from '@components/banner/banner_item';
|
|
||||||
import {
|
|
||||||
BANNER_SPACING,
|
|
||||||
} from '@constants/view';
|
|
||||||
|
|
||||||
import type {FloatingBannerConfig} from './types';
|
|
||||||
|
|
||||||
type AnimatedBannerItemProps = {
|
|
||||||
banner: FloatingBannerConfig;
|
|
||||||
index: number;
|
|
||||||
onBannerPress: (banner: FloatingBannerConfig) => void;
|
|
||||||
onBannerDismiss: (banner: FloatingBannerConfig) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
bannerContainer: {
|
|
||||||
width: '100%',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
animatedBannerWrapper: {
|
|
||||||
width: '100%',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const AnimatedBannerItem: React.FC<AnimatedBannerItemProps> = ({
|
|
||||||
banner,
|
|
||||||
index,
|
|
||||||
onBannerPress,
|
|
||||||
onBannerDismiss,
|
|
||||||
}) => {
|
|
||||||
const {dismissible = true, customComponent} = banner;
|
|
||||||
|
|
||||||
const opacity = useSharedValue(0);
|
|
||||||
const translateY = useSharedValue(20);
|
|
||||||
const hasAnimated = useRef(false);
|
|
||||||
|
|
||||||
const handleDismiss = useCallback(() => onBannerDismiss(banner), [banner, onBannerDismiss]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (hasAnimated.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
hasAnimated.current = true;
|
|
||||||
opacity.value = withTiming(1, {duration: 250});
|
|
||||||
translateY.value = withTiming(0, {duration: 250});
|
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps -- opacity and translateY are sharedValue(s)
|
|
||||||
|
|
||||||
const animatedStyle = useAnimatedStyle(() => ({
|
|
||||||
opacity: opacity.value,
|
|
||||||
transform: [{translateY: translateY.value}],
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.bannerContainer,
|
|
||||||
{marginTop: index > 0 ? BANNER_SPACING : 0},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Animated.View style={[styles.animatedBannerWrapper, animatedStyle]}>
|
|
||||||
<Banner
|
|
||||||
dismissible={dismissible}
|
|
||||||
onDismiss={handleDismiss}
|
|
||||||
>
|
|
||||||
{customComponent || (
|
|
||||||
<BannerItem
|
|
||||||
banner={banner}
|
|
||||||
onPress={onBannerPress}
|
|
||||||
onDismiss={onBannerDismiss}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Banner>
|
|
||||||
</Animated.View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AnimatedBannerItem;
|
|
||||||
|
|
||||||
|
|
@ -1,167 +0,0 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
||||||
// See LICENSE.txt for license information.
|
|
||||||
|
|
||||||
import {render, screen} from '@testing-library/react-native';
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
import * as Device from '@hooks/device';
|
|
||||||
import {useBannerGestureRootPosition} from '@hooks/useBannerGestureRootPosition';
|
|
||||||
|
|
||||||
import BannerSection from './banner_section';
|
|
||||||
|
|
||||||
import type {FloatingBannerConfig} from './types';
|
|
||||||
|
|
||||||
jest.mock('@hooks/device', () => ({
|
|
||||||
useIsTablet: jest.fn(),
|
|
||||||
useKeyboardHeight: jest.fn(),
|
|
||||||
useWindowDimensions: jest.fn(() => ({width: 375, height: 812})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('@hooks/useBannerGestureRootPosition', () => ({
|
|
||||||
useBannerGestureRootPosition: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('react-native-safe-area-context', () => ({
|
|
||||||
useSafeAreaInsets: jest.fn(() => ({top: 44, bottom: 0, left: 0, right: 0})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('react-native-reanimated', () => {
|
|
||||||
const {View} = require('react-native');
|
|
||||||
const ReactLib = require('react');
|
|
||||||
|
|
||||||
const AnimatedView = ReactLib.forwardRef((props: Record<string, unknown>, ref: unknown) => {
|
|
||||||
return ReactLib.createElement(View, {...props, ref});
|
|
||||||
});
|
|
||||||
AnimatedView.displayName = 'AnimatedView';
|
|
||||||
|
|
||||||
return {
|
|
||||||
__esModule: true,
|
|
||||||
useSharedValue: (initialValue: unknown) => ({value: initialValue}),
|
|
||||||
useAnimatedStyle: (fn: () => unknown) => fn(),
|
|
||||||
withTiming: (value: unknown) => value,
|
|
||||||
default: {
|
|
||||||
View: AnimatedView,
|
|
||||||
createAnimatedComponent: (component: unknown) => component,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
jest.mock('@components/banner/Banner', () => {
|
|
||||||
const mockView = require('react-native').View;
|
|
||||||
const mockText = require('react-native').Text;
|
|
||||||
const mockPressable = require('react-native').Pressable;
|
|
||||||
const mockReact = require('react');
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
return function MockBanner({children, onDismiss, ...props}: {children?: any; onDismiss?: any; [key: string]: any}) {
|
|
||||||
return mockReact.createElement(
|
|
||||||
mockView,
|
|
||||||
{testID: 'banner', ...props},
|
|
||||||
children,
|
|
||||||
onDismiss && mockReact.createElement(
|
|
||||||
mockPressable,
|
|
||||||
{testID: 'banner-dismiss', onPress: onDismiss},
|
|
||||||
mockReact.createElement(mockText, null, 'Dismiss'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
jest.mock('@components/banner/banner_item', () => {
|
|
||||||
const mockView = require('react-native').View;
|
|
||||||
const mockText = require('react-native').Text;
|
|
||||||
const mockPressable = require('react-native').Pressable;
|
|
||||||
const mockReact = require('react');
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
return function MockBannerItem({banner, onPress, onDismiss}: {banner: any; onPress?: any; onDismiss?: any}) {
|
|
||||||
return mockReact.createElement(
|
|
||||||
mockView,
|
|
||||||
{testID: 'banner-item', 'data-banner-id': banner.id},
|
|
||||||
mockReact.createElement(mockText, null, banner.title),
|
|
||||||
mockReact.createElement(mockText, null, banner.message),
|
|
||||||
onPress && mockReact.createElement(
|
|
||||||
mockPressable,
|
|
||||||
{testID: 'banner-item-press', onPress: () => onPress(banner)},
|
|
||||||
mockReact.createElement(mockText, null, 'Press'),
|
|
||||||
),
|
|
||||||
onDismiss && mockReact.createElement(
|
|
||||||
mockPressable,
|
|
||||||
{testID: 'banner-item-dismiss', onPress: () => onDismiss(banner)},
|
|
||||||
mockReact.createElement(mockText, null, 'Dismiss'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('BannerSection', () => {
|
|
||||||
const mockOnBannerPress = jest.fn();
|
|
||||||
const mockOnBannerDismiss = jest.fn();
|
|
||||||
|
|
||||||
const createMockBanner = (overrides: Partial<FloatingBannerConfig> = {}): FloatingBannerConfig => ({
|
|
||||||
id: 'test-banner-1',
|
|
||||||
title: 'Test Banner',
|
|
||||||
message: 'This is a test message',
|
|
||||||
type: 'info',
|
|
||||||
dismissible: true,
|
|
||||||
...overrides,
|
|
||||||
});
|
|
||||||
|
|
||||||
const renderBannerSection = (
|
|
||||||
sectionBanners: FloatingBannerConfig[],
|
|
||||||
position: 'top' | 'bottom' = 'top',
|
|
||||||
) => {
|
|
||||||
return render(
|
|
||||||
<BannerSection
|
|
||||||
sectionBanners={sectionBanners}
|
|
||||||
position={position}
|
|
||||||
onBannerPress={mockOnBannerPress}
|
|
||||||
onBannerDismiss={mockOnBannerDismiss}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
jest.mocked(Device.useIsTablet).mockReturnValue(false);
|
|
||||||
jest.mocked(Device.useKeyboardHeight).mockReturnValue(0);
|
|
||||||
jest.mocked(useBannerGestureRootPosition).mockReturnValue({
|
|
||||||
height: 40,
|
|
||||||
top: 0,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('rendering', () => {
|
|
||||||
it('should return null when no banners are provided', () => {
|
|
||||||
renderBannerSection([], 'top');
|
|
||||||
|
|
||||||
expect(screen.queryByTestId('floating-banner-top-container')).toBeNull();
|
|
||||||
expect(screen.queryByTestId('floating-banner-bottom-container')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render top container with correct testID', () => {
|
|
||||||
const banners = [createMockBanner()];
|
|
||||||
renderBannerSection(banners, 'top');
|
|
||||||
|
|
||||||
expect(screen.getByTestId('floating-banner-top-container')).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render bottom container with correct testID', () => {
|
|
||||||
const banners = [createMockBanner()];
|
|
||||||
renderBannerSection(banners, 'bottom');
|
|
||||||
|
|
||||||
expect(screen.getByTestId('floating-banner-bottom-container')).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render multiple banners', () => {
|
|
||||||
const banners = [
|
|
||||||
createMockBanner({id: 'banner-1'}),
|
|
||||||
createMockBanner({id: 'banner-2'}),
|
|
||||||
createMockBanner({id: 'banner-3'}),
|
|
||||||
];
|
|
||||||
renderBannerSection(banners);
|
|
||||||
|
|
||||||
const bannerElements = screen.getAllByTestId('banner');
|
|
||||||
expect(bannerElements).toHaveLength(3);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
@ -1,109 +0,0 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
||||||
// See LICENSE.txt for license information.
|
|
||||||
|
|
||||||
import React, {useMemo} from 'react';
|
|
||||||
import {StyleSheet} from 'react-native';
|
|
||||||
import {GestureHandlerRootView} from 'react-native-gesture-handler';
|
|
||||||
import Animated, {useAnimatedStyle} from 'react-native-reanimated';
|
|
||||||
import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
|
||||||
|
|
||||||
import {
|
|
||||||
BANNER_SPACING,
|
|
||||||
} from '@constants/view';
|
|
||||||
import {useBannerGestureRootPosition} from '@hooks/useBannerGestureRootPosition';
|
|
||||||
|
|
||||||
import AnimatedBannerItem from './animated_banner_item';
|
|
||||||
|
|
||||||
import type {FloatingBannerConfig, FloatingBannerPosition} from './types';
|
|
||||||
|
|
||||||
type BannerSectionProps = {
|
|
||||||
sectionBanners: FloatingBannerConfig[];
|
|
||||||
position: FloatingBannerPosition;
|
|
||||||
onBannerPress: (banner: FloatingBannerConfig) => void;
|
|
||||||
onBannerDismiss: (banner: FloatingBannerConfig) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const BANNER_HEIGHT = 40;
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
gestureRoot: {
|
|
||||||
position: 'absolute',
|
|
||||||
width: '100%',
|
|
||||||
},
|
|
||||||
containerBase: {
|
|
||||||
width: '100%',
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
topContainer: {
|
|
||||||
top: 0,
|
|
||||||
paddingTop: 8,
|
|
||||||
},
|
|
||||||
bottomContainer: {
|
|
||||||
paddingBottom: 8,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const BannerSection: React.FC<BannerSectionProps> = ({
|
|
||||||
sectionBanners,
|
|
||||||
position,
|
|
||||||
onBannerPress,
|
|
||||||
onBannerDismiss,
|
|
||||||
}) => {
|
|
||||||
if (!sectionBanners.length) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const isTop = position === 'top';
|
|
||||||
const testID = isTop ? 'floating-banner-top-container' : 'floating-banner-bottom-container';
|
|
||||||
|
|
||||||
const containerStyle = isTop ? styles.topContainer :styles.bottomContainer;
|
|
||||||
|
|
||||||
const animatedStyle = useAnimatedStyle(() => {
|
|
||||||
if (isTop) {
|
|
||||||
return {paddingTop: insets.top + 8};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {};
|
|
||||||
}, [isTop, insets.top]);
|
|
||||||
|
|
||||||
// Limitation: Assumes all banners have the same fixed height (BANNER_HEIGHT).
|
|
||||||
// If banners have different heights, the gesture area may not align perfectly.
|
|
||||||
// Future improvement: Measure actual banner heights using onLayout for dynamic sizing.
|
|
||||||
const containerHeight = useMemo(() => {
|
|
||||||
const numBanners = sectionBanners.length;
|
|
||||||
const spacing = (numBanners - 1) * BANNER_SPACING;
|
|
||||||
return (BANNER_HEIGHT * numBanners) + spacing;
|
|
||||||
}, [sectionBanners.length]);
|
|
||||||
|
|
||||||
const gestureRootStyle = useBannerGestureRootPosition({
|
|
||||||
position,
|
|
||||||
containerHeight,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<GestureHandlerRootView
|
|
||||||
style={[styles.gestureRoot, gestureRootStyle]}
|
|
||||||
pointerEvents='box-none'
|
|
||||||
>
|
|
||||||
<Animated.View
|
|
||||||
testID={testID}
|
|
||||||
style={[styles.containerBase, containerStyle, animatedStyle]}
|
|
||||||
>
|
|
||||||
{sectionBanners.map((banner, index) => (
|
|
||||||
<AnimatedBannerItem
|
|
||||||
key={banner.id}
|
|
||||||
banner={banner}
|
|
||||||
index={index}
|
|
||||||
onBannerPress={onBannerPress}
|
|
||||||
onBannerDismiss={onBannerDismiss}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Animated.View>
|
|
||||||
</GestureHandlerRootView>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default BannerSection;
|
|
||||||
|
|
||||||
|
|
@ -1,256 +0,0 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
||||||
// See LICENSE.txt for license information.
|
|
||||||
|
|
||||||
import {render, fireEvent, screen} from '@testing-library/react-native';
|
|
||||||
import React from 'react';
|
|
||||||
import {Text} from 'react-native';
|
|
||||||
|
|
||||||
import * as Device from '@hooks/device';
|
|
||||||
|
|
||||||
import FloatingBanner from './floating_banner';
|
|
||||||
|
|
||||||
import type {FloatingBannerConfig} from './types';
|
|
||||||
|
|
||||||
jest.mock('@hooks/device');
|
|
||||||
|
|
||||||
jest.mock('@hooks/useBannerGestureRootPosition', () => ({
|
|
||||||
useBannerGestureRootPosition: jest.fn(() => ({
|
|
||||||
height: 40,
|
|
||||||
top: 0,
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('react-native-reanimated', () => {
|
|
||||||
const {View} = require('react-native');
|
|
||||||
const ReactLib = require('react');
|
|
||||||
|
|
||||||
const AnimatedView = ReactLib.forwardRef((props: Record<string, unknown>, ref: unknown) => {
|
|
||||||
return ReactLib.createElement(View, {...props, ref});
|
|
||||||
});
|
|
||||||
AnimatedView.displayName = 'AnimatedView';
|
|
||||||
|
|
||||||
return {
|
|
||||||
__esModule: true,
|
|
||||||
useAnimatedStyle: (fn: () => unknown) => fn(),
|
|
||||||
useSharedValue: (initialValue: unknown) => ({value: initialValue}),
|
|
||||||
withTiming: (value: unknown) => value,
|
|
||||||
createAnimatedComponent: (component: unknown) => component,
|
|
||||||
default: {
|
|
||||||
View: AnimatedView,
|
|
||||||
createAnimatedComponent: (component: unknown) => component,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
jest.mock('@components/banner/Banner', () => {
|
|
||||||
const mockView = require('react-native').View;
|
|
||||||
const mockText = require('react-native').Text;
|
|
||||||
const mockPressable = require('react-native').Pressable;
|
|
||||||
const mockReact = require('react');
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
return function MockBanner({children, onDismiss, ...props}: {children?: any; onDismiss?: any; [key: string]: any}) {
|
|
||||||
return mockReact.createElement(
|
|
||||||
mockView,
|
|
||||||
{testID: 'banner', ...props},
|
|
||||||
children,
|
|
||||||
onDismiss && mockReact.createElement(
|
|
||||||
mockPressable,
|
|
||||||
{testID: 'banner-dismiss', onPress: onDismiss},
|
|
||||||
mockReact.createElement(mockText, null, 'Dismiss'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
jest.mock('@components/banner/banner_item', () => {
|
|
||||||
const mockView = require('react-native').View;
|
|
||||||
const mockText = require('react-native').Text;
|
|
||||||
const mockPressable = require('react-native').Pressable;
|
|
||||||
const mockReact = require('react');
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
return function MockBannerItem({banner, onPress, onDismiss}: {banner: any; onPress?: any; onDismiss?: any}) {
|
|
||||||
return mockReact.createElement(
|
|
||||||
mockView,
|
|
||||||
{testID: 'banner-item', 'data-banner-id': banner.id},
|
|
||||||
mockReact.createElement(mockText, null, banner.title),
|
|
||||||
mockReact.createElement(mockText, null, banner.message),
|
|
||||||
onPress && mockReact.createElement(
|
|
||||||
mockPressable,
|
|
||||||
{testID: 'banner-item-press', onPress: () => onPress(banner)},
|
|
||||||
mockReact.createElement(mockText, null, 'Press'),
|
|
||||||
),
|
|
||||||
onDismiss && mockReact.createElement(
|
|
||||||
mockPressable,
|
|
||||||
{testID: 'banner-item-dismiss', onPress: () => onDismiss(banner)},
|
|
||||||
mockReact.createElement(mockText, null, 'Dismiss'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('FloatingBanner', () => {
|
|
||||||
const mockOnPress = jest.fn();
|
|
||||||
const mockOnDismiss = jest.fn();
|
|
||||||
|
|
||||||
const createMockBanner = (overrides: Partial<FloatingBannerConfig> = {}): FloatingBannerConfig => ({
|
|
||||||
id: 'test-banner-1',
|
|
||||||
title: 'Test Banner',
|
|
||||||
message: 'This is a test message',
|
|
||||||
type: 'info',
|
|
||||||
dismissible: true,
|
|
||||||
onPress: mockOnPress,
|
|
||||||
onDismiss: mockOnDismiss,
|
|
||||||
...overrides,
|
|
||||||
});
|
|
||||||
|
|
||||||
const mockOverlayOnDismiss = jest.fn();
|
|
||||||
|
|
||||||
const renderFloatingBanner = (banners: FloatingBannerConfig[] = []) => {
|
|
||||||
return render(
|
|
||||||
<FloatingBanner
|
|
||||||
banners={banners}
|
|
||||||
onDismiss={mockOverlayOnDismiss}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
mockOverlayOnDismiss.mockClear();
|
|
||||||
|
|
||||||
jest.mocked(Device.useIsTablet).mockReturnValue(false);
|
|
||||||
jest.mocked(Device.useKeyboardHeight).mockReturnValue(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('rendering', () => {
|
|
||||||
it('should render nothing when no banners are present', () => {
|
|
||||||
renderFloatingBanner([]);
|
|
||||||
|
|
||||||
expect(screen.queryByTestId('banner')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render single banner with correct props', () => {
|
|
||||||
const banner = createMockBanner();
|
|
||||||
renderFloatingBanner([banner]);
|
|
||||||
|
|
||||||
const bannerElement = screen.getByTestId('banner');
|
|
||||||
expect(bannerElement.props.dismissible).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render multiple banners with correct offsets', () => {
|
|
||||||
const banners = [
|
|
||||||
createMockBanner({id: 'banner-1'}),
|
|
||||||
createMockBanner({id: 'banner-2'}),
|
|
||||||
createMockBanner({id: 'banner-3'}),
|
|
||||||
];
|
|
||||||
renderFloatingBanner(banners);
|
|
||||||
|
|
||||||
const bannerElements = screen.getAllByTestId('banner');
|
|
||||||
expect(bannerElements).toHaveLength(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use banner position when specified', () => {
|
|
||||||
const banner = createMockBanner({position: 'bottom'});
|
|
||||||
renderFloatingBanner([banner]);
|
|
||||||
|
|
||||||
const bannerElement = screen.getByTestId('banner');
|
|
||||||
expect(bannerElement).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('event handlers', () => {
|
|
||||||
it('should call banner onPress when onBannerPress is triggered', () => {
|
|
||||||
const banner = createMockBanner();
|
|
||||||
renderFloatingBanner([banner]);
|
|
||||||
|
|
||||||
const pressButton = screen.getByTestId('banner-item-press');
|
|
||||||
fireEvent.press(pressButton);
|
|
||||||
|
|
||||||
expect(mockOnPress).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not call onPress when banner has no onPress handler', () => {
|
|
||||||
const banner = createMockBanner({onPress: undefined});
|
|
||||||
renderFloatingBanner([banner]);
|
|
||||||
|
|
||||||
const pressButton = screen.getByTestId('banner-item-press');
|
|
||||||
fireEvent.press(pressButton);
|
|
||||||
|
|
||||||
expect(mockOnPress).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call onDismiss and banner onDismiss when onBannerDismiss is triggered', () => {
|
|
||||||
const banner = createMockBanner();
|
|
||||||
renderFloatingBanner([banner]);
|
|
||||||
|
|
||||||
const dismissButton = screen.getByTestId('banner-item-dismiss');
|
|
||||||
fireEvent.press(dismissButton);
|
|
||||||
|
|
||||||
expect(mockOverlayOnDismiss).toHaveBeenCalledWith('test-banner-1');
|
|
||||||
expect(mockOnDismiss).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should only call onDismiss when banner has no onDismiss handler', () => {
|
|
||||||
const banner = createMockBanner({onDismiss: undefined});
|
|
||||||
renderFloatingBanner([banner]);
|
|
||||||
|
|
||||||
const dismissButton = screen.getByTestId('banner-item-dismiss');
|
|
||||||
fireEvent.press(dismissButton);
|
|
||||||
|
|
||||||
expect(mockOverlayOnDismiss).toHaveBeenCalledWith('test-banner-1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call onBannerDismiss when Banner onDismiss is triggered', () => {
|
|
||||||
const banner = createMockBanner();
|
|
||||||
renderFloatingBanner([banner]);
|
|
||||||
|
|
||||||
const bannerDismissButton = screen.getByTestId('banner-dismiss');
|
|
||||||
fireEvent.press(bannerDismissButton);
|
|
||||||
|
|
||||||
expect(mockOverlayOnDismiss).toHaveBeenCalledWith('test-banner-1');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('banner positioning', () => {
|
|
||||||
it('should separate banners by position (top vs bottom)', () => {
|
|
||||||
const banners = [
|
|
||||||
createMockBanner({
|
|
||||||
id: 'info-banner',
|
|
||||||
type: 'info',
|
|
||||||
position: 'top',
|
|
||||||
dismissible: true,
|
|
||||||
}),
|
|
||||||
createMockBanner({
|
|
||||||
id: 'error-banner',
|
|
||||||
type: 'error',
|
|
||||||
position: 'bottom',
|
|
||||||
dismissible: false,
|
|
||||||
}),
|
|
||||||
createMockBanner({
|
|
||||||
id: 'custom-banner',
|
|
||||||
customComponent: <Text testID={'custom-banner'}>{'Custom'}</Text>,
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
renderFloatingBanner(banners);
|
|
||||||
|
|
||||||
const bannerElements = screen.getAllByTestId('banner');
|
|
||||||
expect(bannerElements).toHaveLength(3);
|
|
||||||
|
|
||||||
expect(screen.getByTestId('floating-banner-top-container')).toBeDefined();
|
|
||||||
expect(screen.getByTestId('floating-banner-bottom-container')).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('position filtering', () => {
|
|
||||||
it('uses default top when position is undefined', () => {
|
|
||||||
const banners = [
|
|
||||||
createMockBanner({id: 'no-pos-1', position: undefined}),
|
|
||||||
createMockBanner({id: 'no-pos-2', position: undefined}),
|
|
||||||
];
|
|
||||||
renderFloatingBanner(banners);
|
|
||||||
|
|
||||||
const bannerElements = screen.getAllByTestId('banner');
|
|
||||||
expect(bannerElements).toHaveLength(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
||||||
// See LICENSE.txt for license information.
|
|
||||||
|
|
||||||
import React, {useCallback, useMemo} from 'react';
|
|
||||||
|
|
||||||
import BannerSection from './banner_section';
|
|
||||||
|
|
||||||
import type {FloatingBannerConfig} from './types';
|
|
||||||
|
|
||||||
type FloatingBannerProps = {
|
|
||||||
banners: FloatingBannerConfig[];
|
|
||||||
onDismiss: (id: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const FloatingBanner: React.FC<FloatingBannerProps> = ({banners, onDismiss}) => {
|
|
||||||
if (!banners || !banners.length) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const onBannerPress = useCallback((banner: FloatingBannerConfig) => {
|
|
||||||
if (banner.onPress) {
|
|
||||||
banner.onPress();
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onBannerDismiss = useCallback((banner: FloatingBannerConfig) => {
|
|
||||||
onDismiss(banner.id);
|
|
||||||
if (banner.onDismiss) {
|
|
||||||
banner.onDismiss();
|
|
||||||
}
|
|
||||||
}, [onDismiss]);
|
|
||||||
|
|
||||||
const topBanners = useMemo(
|
|
||||||
() => banners.filter((banner) => (banner.position || 'top') === 'top'),
|
|
||||||
[banners],
|
|
||||||
);
|
|
||||||
|
|
||||||
const bottomBanners = useMemo(
|
|
||||||
() => banners.filter((banner) => banner.position === 'bottom'),
|
|
||||||
[banners],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<BannerSection
|
|
||||||
sectionBanners={topBanners}
|
|
||||||
position='top'
|
|
||||||
onBannerPress={onBannerPress}
|
|
||||||
onBannerDismiss={onBannerDismiss}
|
|
||||||
/>
|
|
||||||
<BannerSection
|
|
||||||
sectionBanners={bottomBanners}
|
|
||||||
position='bottom'
|
|
||||||
onBannerPress={onBannerPress}
|
|
||||||
onBannerDismiss={onBannerDismiss}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default FloatingBanner;
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
||||||
// See LICENSE.txt for license information.
|
|
||||||
|
|
||||||
import type {ReactNode} from 'react';
|
|
||||||
|
|
||||||
export type FloatingBannerPosition = 'top' | 'bottom';
|
|
||||||
|
|
||||||
export interface FloatingBannerConfig {
|
|
||||||
id: string;
|
|
||||||
title?: string;
|
|
||||||
message?: string;
|
|
||||||
type?: 'info' | 'success' | 'warning' | 'error';
|
|
||||||
dismissible?: boolean;
|
|
||||||
autoHideDuration?: number;
|
|
||||||
position?: FloatingBannerPosition;
|
|
||||||
onPress?: () => void;
|
|
||||||
onDismiss?: () => void;
|
|
||||||
customComponent?: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
@ -6,7 +6,6 @@ import {type IntlShape, useIntl} from 'react-intl';
|
||||||
import {Text, View, type StyleProp, type TextStyle, type ViewStyle} from 'react-native';
|
import {Text, View, type StyleProp, type TextStyle, type ViewStyle} from 'react-native';
|
||||||
|
|
||||||
import CompassIcon from '@components/compass_icon';
|
import CompassIcon from '@components/compass_icon';
|
||||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
|
||||||
import {Screens, View as ViewConstants} from '@constants';
|
import {Screens, View as ViewConstants} from '@constants';
|
||||||
import {useServerUrl} from '@context/server';
|
import {useServerUrl} from '@context/server';
|
||||||
import {useTheme} from '@context/theme';
|
import {useTheme} from '@context/theme';
|
||||||
|
|
@ -48,6 +47,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
flex: 1,
|
||||||
},
|
},
|
||||||
dropdownPlaceholder: {
|
dropdownPlaceholder: {
|
||||||
color: changeOpacity(theme.centerChannelColor, 0.5),
|
color: changeOpacity(theme.centerChannelColor, 0.5),
|
||||||
|
|
@ -172,16 +172,20 @@ function AutoCompleteSelector({
|
||||||
Promise.all(namePromises).then((names) => {
|
Promise.all(namePromises).then((names) => {
|
||||||
setItemText(names.join(', '));
|
setItemText(names.join(', '));
|
||||||
});
|
});
|
||||||
}, [dataSource, teammateNameDisplay, intl, options, selected, serverUrl]);
|
|
||||||
|
|
||||||
const touchableStyle = useMemo(() => {
|
// We want to run this only in the first render, since it is only for the default value.
|
||||||
const res: StyleProp<ViewStyle> = [{flex: 1}];
|
// Future changes in the selected value will update the itemText accordingly.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const inputStyle = useMemo(() => {
|
||||||
|
const res: StyleProp<ViewStyle> = [style.input];
|
||||||
if (disabled) {
|
if (disabled) {
|
||||||
res.push(style.disabled);
|
res.push(style.disabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
return res;
|
return res;
|
||||||
}, [disabled, style.disabled]);
|
}, [disabled, style.disabled, style.input]);
|
||||||
|
|
||||||
const dropdownTextStyle = useMemo(() => {
|
const dropdownTextStyle = useMemo(() => {
|
||||||
const res: StyleProp<TextStyle> = [style.dropdownText];
|
const res: StyleProp<TextStyle> = [style.dropdownText];
|
||||||
|
|
@ -200,30 +204,25 @@ function AutoCompleteSelector({
|
||||||
error={errorText}
|
error={errorText}
|
||||||
hideErrorIcon={true}
|
hideErrorIcon={true}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
|
focus={goToSelectorScreen}
|
||||||
focused={false}
|
focused={false}
|
||||||
focusedLabel={focusedLabel}
|
focusedLabel={focusedLabel}
|
||||||
editable={!disabled}
|
editable={!disabled}
|
||||||
testID={testID}
|
testID={testID}
|
||||||
>
|
>
|
||||||
<TouchableWithFeedback
|
<View style={inputStyle}>
|
||||||
disabled={disabled}
|
<Text
|
||||||
onPress={goToSelectorScreen}
|
numberOfLines={1}
|
||||||
style={touchableStyle}
|
style={dropdownTextStyle}
|
||||||
type='opacity'
|
>
|
||||||
>
|
{itemText || placeholder}
|
||||||
<View style={style.input}>
|
</Text>
|
||||||
<Text
|
<CompassIcon
|
||||||
numberOfLines={1}
|
name='chevron-down'
|
||||||
style={dropdownTextStyle}
|
size={20}
|
||||||
>
|
color={changeOpacity(theme.centerChannelColor, 0.5)}
|
||||||
{itemText || placeholder}
|
/>
|
||||||
</Text>
|
</View>
|
||||||
<CompassIcon
|
|
||||||
name='chevron-down'
|
|
||||||
color={changeOpacity(theme.centerChannelColor, 0.5)}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</TouchableWithFeedback>
|
|
||||||
</FloatingInputContainer>
|
</FloatingInputContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,7 @@ const FloatingInputContainer = ({
|
||||||
testID,
|
testID,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const styles = getStyleSheet(theme);
|
const styles = getStyleSheet(theme);
|
||||||
const positions = useMemo(() => getLabelPositions(styles.textInput, styles.label, styles.smallLabel), [styles]);
|
const positions = useMemo(() => getLabelPositions(styles.textInput, styles.bigLabel, styles.smallLabel), [styles]);
|
||||||
const errorIcon = 'alert-outline';
|
const errorIcon = 'alert-outline';
|
||||||
const shouldShowError = !focused && error;
|
const shouldShowError = !focused && error;
|
||||||
|
|
||||||
|
|
@ -189,7 +189,7 @@ const FloatingInputContainer = ({
|
||||||
paddingHorizontal: focusedLabel || inputText ? 4 : 0,
|
paddingHorizontal: focusedLabel || inputText ? 4 : 0,
|
||||||
color,
|
color,
|
||||||
};
|
};
|
||||||
});
|
}, [styles, theme, focusedLabel, hasValue, shouldShowError, positions]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableWithoutFeedback
|
<TouchableWithoutFeedback
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import React, {useState, useRef, useImperativeHandle, forwardRef, useMemo, useCa
|
||||||
import {type LayoutChangeEvent, type NativeSyntheticEvent, type StyleProp, type TargetedEvent, TextInput, type TextInputFocusEventData, type TextInputProps, type TextStyle} from 'react-native';
|
import {type LayoutChangeEvent, type NativeSyntheticEvent, type StyleProp, type TargetedEvent, TextInput, type TextInputFocusEventData, type TextInputProps, type TextStyle} from 'react-native';
|
||||||
|
|
||||||
import {changeOpacity, getKeyboardAppearanceFromTheme, makeStyleSheetFromTheme} from '@utils/theme';
|
import {changeOpacity, getKeyboardAppearanceFromTheme, makeStyleSheetFromTheme} from '@utils/theme';
|
||||||
|
import {typography} from '@utils/typography';
|
||||||
|
|
||||||
import FloatingInputContainer from './floating_input_container';
|
import FloatingInputContainer from './floating_input_container';
|
||||||
import {onExecution} from './utils';
|
import {onExecution} from './utils';
|
||||||
|
|
@ -22,11 +23,10 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
||||||
paddingTop: 0,
|
paddingTop: 0,
|
||||||
paddingBottom: 0,
|
paddingBottom: 0,
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
fontFamily: 'OpenSans',
|
|
||||||
fontSize: 16,
|
|
||||||
color: theme.centerChannelColor,
|
color: theme.centerChannelColor,
|
||||||
borderColor: changeOpacity(theme.centerChannelColor, 0.16),
|
borderColor: changeOpacity(theme.centerChannelColor, 0.16),
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
|
...typography('Body', 200),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,14 +9,13 @@ describe('getLabelPositions', () => {
|
||||||
paddingTop: 10,
|
paddingTop: 10,
|
||||||
paddingBottom: 10,
|
paddingBottom: 10,
|
||||||
height: 50,
|
height: 50,
|
||||||
fontSize: 14,
|
|
||||||
padding: 20,
|
padding: 20,
|
||||||
};
|
};
|
||||||
const labelStyle = {fontSize: 15};
|
const labelStyle = {fontSize: 15};
|
||||||
const smallLabelStyle = {fontSize: 11};
|
const smallLabelStyle = {fontSize: 11};
|
||||||
|
|
||||||
const result = getLabelPositions(style, labelStyle, smallLabelStyle);
|
const result = getLabelPositions(style, labelStyle, smallLabelStyle);
|
||||||
expect(result).toEqual([24.4, -6.5]);
|
expect(result).toEqual([25, -6.5]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should return correct positions when label and smallLabels styles are missing', () => {
|
test('should return correct positions when label and smallLabels styles are missing', () => {
|
||||||
|
|
@ -24,14 +23,13 @@ describe('getLabelPositions', () => {
|
||||||
paddingTop: 15,
|
paddingTop: 15,
|
||||||
paddingBottom: 15,
|
paddingBottom: 15,
|
||||||
height: 50,
|
height: 50,
|
||||||
fontSize: 14,
|
|
||||||
padding: 25,
|
padding: 25,
|
||||||
};
|
};
|
||||||
const labelStyle = {};
|
const labelStyle = {};
|
||||||
const smallLabelStyle = {};
|
const smallLabelStyle = {};
|
||||||
|
|
||||||
const result = getLabelPositions(style, labelStyle, smallLabelStyle);
|
const result = getLabelPositions(style, labelStyle, smallLabelStyle);
|
||||||
expect(result).toEqual([23.8, -6.5]);
|
expect(result).toEqual([23.2, -6.5]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should return correct positions when all values are empty are provided', () => {
|
test('should return correct positions when all values are empty are provided', () => {
|
||||||
|
|
@ -49,7 +47,6 @@ describe('getLabelPositions', () => {
|
||||||
paddingTop: 0,
|
paddingTop: 0,
|
||||||
paddingBottom: 0,
|
paddingBottom: 0,
|
||||||
height: 0,
|
height: 0,
|
||||||
fontSize: 0,
|
|
||||||
padding: 0,
|
padding: 0,
|
||||||
};
|
};
|
||||||
const labelStyle = {fontSize: 0};
|
const labelStyle = {fontSize: 0};
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ export const getLabelPositions = (style: TextStyle, labelStyle: TextStyle, small
|
||||||
const bottom: number = style.paddingBottom as number || 0;
|
const bottom: number = style.paddingBottom as number || 0;
|
||||||
|
|
||||||
const height: number = (style.height as number || (top + bottom) || style.padding as number) || 0;
|
const height: number = (style.height as number || (top + bottom) || style.padding as number) || 0;
|
||||||
const textInputFontSize = style.fontSize || 13;
|
const textInputFontSize = labelStyle.fontSize || 13;
|
||||||
const labelFontSize = labelStyle.fontSize || 16;
|
const labelFontSize = labelStyle.fontSize || 16;
|
||||||
const smallLabelFontSize = smallLabelStyle.fontSize || 10;
|
const smallLabelFontSize = smallLabelStyle.fontSize || 10;
|
||||||
const fontSizeDiff = textInputFontSize - labelFontSize;
|
const fontSizeDiff = textInputFontSize - labelFontSize;
|
||||||
|
|
|
||||||
|
|
@ -48,17 +48,15 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||||
return {
|
return {
|
||||||
actionContainer: {
|
actionContainer: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'flex-start',
|
alignItems: 'center',
|
||||||
justifyContent: 'flex-end',
|
justifyContent: 'flex-end',
|
||||||
marginTop: 2,
|
|
||||||
},
|
},
|
||||||
container: {
|
container: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'flex-start',
|
alignItems: 'center',
|
||||||
minHeight: ITEM_HEIGHT,
|
minHeight: ITEM_HEIGHT,
|
||||||
gap: 12,
|
gap: 12,
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
paddingVertical: 12,
|
|
||||||
},
|
},
|
||||||
destructive: {
|
destructive: {
|
||||||
color: theme.dndIndicator,
|
color: theme.dndIndicator,
|
||||||
|
|
|
||||||
|
|
@ -84,10 +84,9 @@ export const GLOBAL_IDENTIFIERS = {
|
||||||
DONT_ASK_FOR_REVIEW: 'dontAskForReview',
|
DONT_ASK_FOR_REVIEW: 'dontAskForReview',
|
||||||
FIRST_LAUNCH: 'firstLaunch',
|
FIRST_LAUNCH: 'firstLaunch',
|
||||||
LAST_ASK_FOR_REVIEW: 'lastAskForReview',
|
LAST_ASK_FOR_REVIEW: 'lastAskForReview',
|
||||||
|
ONBOARDING: 'onboarding',
|
||||||
LAST_VIEWED_CHANNEL: 'lastViewedChannel',
|
LAST_VIEWED_CHANNEL: 'lastViewedChannel',
|
||||||
LAST_VIEWED_THREAD: 'lastViewedThread',
|
LAST_VIEWED_THREAD: 'lastViewedThread',
|
||||||
LOW_CONNECTIVITY_MONITOR: 'lowConnectivityMonitor',
|
|
||||||
ONBOARDING: 'onboarding',
|
|
||||||
PUSH_DISABLED_ACK: 'pushDisabledAck',
|
PUSH_DISABLED_ACK: 'pushDisabledAck',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,6 @@ export const FIND_CHANNELS = 'FindChannels';
|
||||||
export const FORGOT_PASSWORD = 'ForgotPassword';
|
export const FORGOT_PASSWORD = 'ForgotPassword';
|
||||||
export const GALLERY = 'Gallery';
|
export const GALLERY = 'Gallery';
|
||||||
export const GENERIC_OVERLAY = 'GenericOverlay';
|
export const GENERIC_OVERLAY = 'GenericOverlay';
|
||||||
export const FLOATING_BANNER = 'FloatingBanner';
|
|
||||||
export const GLOBAL_DRAFTS = 'GlobalDrafts';
|
export const GLOBAL_DRAFTS = 'GlobalDrafts';
|
||||||
export const GLOBAL_DRAFTS_AND_SCHEDULED_POSTS = 'GlobalDraftsAndScheduledPosts';
|
export const GLOBAL_DRAFTS_AND_SCHEDULED_POSTS = 'GlobalDraftsAndScheduledPosts';
|
||||||
export const GLOBAL_THREADS = 'GlobalThreads';
|
export const GLOBAL_THREADS = 'GlobalThreads';
|
||||||
|
|
@ -124,7 +123,6 @@ export default {
|
||||||
FORGOT_PASSWORD,
|
FORGOT_PASSWORD,
|
||||||
GALLERY,
|
GALLERY,
|
||||||
GENERIC_OVERLAY,
|
GENERIC_OVERLAY,
|
||||||
FLOATING_BANNER,
|
|
||||||
GLOBAL_DRAFTS,
|
GLOBAL_DRAFTS,
|
||||||
GLOBAL_DRAFTS_AND_SCHEDULED_POSTS,
|
GLOBAL_DRAFTS_AND_SCHEDULED_POSTS,
|
||||||
GLOBAL_THREADS,
|
GLOBAL_THREADS,
|
||||||
|
|
@ -205,7 +203,6 @@ export const SCREENS_WITH_TRANSPARENT_BACKGROUND = new Set<string>([
|
||||||
REVIEW_APP,
|
REVIEW_APP,
|
||||||
SNACK_BAR,
|
SNACK_BAR,
|
||||||
GENERIC_OVERLAY,
|
GENERIC_OVERLAY,
|
||||||
FLOATING_BANNER,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const SCREENS_AS_BOTTOM_SHEET = new Set<string>([
|
export const SCREENS_AS_BOTTOM_SHEET = new Set<string>([
|
||||||
|
|
|
||||||
|
|
@ -33,15 +33,6 @@ export const ANNOUNCEMENT_BAR_HEIGHT = 40;
|
||||||
export const BOOKMARKS_BAR_HEIGHT = 48;
|
export const BOOKMARKS_BAR_HEIGHT = 48;
|
||||||
export const CHANNEL_BANNER_HEIGHT = 40;
|
export const CHANNEL_BANNER_HEIGHT = 40;
|
||||||
|
|
||||||
export const FLOATING_BANNER_BOTTOM_OFFSET_PHONE_IOS = 121;
|
|
||||||
export const FLOATING_BANNER_BOTTOM_OFFSET_PHONE_ANDROID = 98;
|
|
||||||
export const FLOATING_BANNER_BOTTOM_OFFSET_WITH_KEYBOARD_IOS = 78;
|
|
||||||
export const FLOATING_BANNER_BOTTOM_OFFSET_WITH_KEYBOARD_ANDROID = 88;
|
|
||||||
export const FLOATING_BANNER_TABLET_EXTRA_BOTTOM_OFFSET = 68;
|
|
||||||
export const FLOATING_BANNER_STACK_SPACING = 60;
|
|
||||||
export const FLOATING_BANNER_EXTRA_OFFSET = 8;
|
|
||||||
export const BANNER_SPACING = 8;
|
|
||||||
|
|
||||||
export const HOME_PADDING = {
|
export const HOME_PADDING = {
|
||||||
paddingLeft: 18,
|
paddingLeft: 18,
|
||||||
paddingRight: 20,
|
paddingRight: 20,
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,6 @@ import type {KeyboardAwareScrollView} from 'react-native-keyboard-aware-scroll-v
|
||||||
|
|
||||||
let utilsEmitter = new NativeEventEmitter(RNUtils);
|
let utilsEmitter = new NativeEventEmitter(RNUtils);
|
||||||
|
|
||||||
const isOlderAndroidOS = Platform.OS === 'android' && Platform.Version < 30;
|
|
||||||
|
|
||||||
export function testSetUtilsEmitter(emitter: NativeEventEmitter) {
|
export function testSetUtilsEmitter(emitter: NativeEventEmitter) {
|
||||||
utilsEmitter = emitter;
|
utilsEmitter = emitter;
|
||||||
}
|
}
|
||||||
|
|
@ -62,17 +60,9 @@ export function useKeyboardHeightWithDuration() {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (Platform.OS === 'ios') {
|
|
||||||
const currentKeyboardMetrics = Keyboard.metrics();
|
|
||||||
|
|
||||||
if (Keyboard.isVisible() && currentKeyboardMetrics) {
|
|
||||||
setKeyboardHeight({height: currentKeyboardMetrics.height, duration: 0});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const show = Keyboard.addListener(Platform.select({ios: 'keyboardWillShow', default: 'keyboardDidShow'}), async (event) => {
|
const show = Keyboard.addListener(Platform.select({ios: 'keyboardWillShow', default: 'keyboardDidShow'}), async (event) => {
|
||||||
// Do not use set the height on Android versions below 11
|
// Do not use set the height on Android versions below 11
|
||||||
if (isOlderAndroidOS) {
|
if (Platform.OS === 'android' && Platform.Version < 30) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setKeyboardHeight({height: event.endCoordinates.height, duration: event.duration});
|
setKeyboardHeight({height: event.endCoordinates.height, duration: event.duration});
|
||||||
|
|
@ -80,7 +70,7 @@ export function useKeyboardHeightWithDuration() {
|
||||||
|
|
||||||
const hide = Keyboard.addListener(Platform.select({ios: 'keyboardWillHide', default: 'keyboardDidHide'}), (event) => {
|
const hide = Keyboard.addListener(Platform.select({ios: 'keyboardWillHide', default: 'keyboardDidHide'}), (event) => {
|
||||||
// Do not use set the height on Android versions below 11
|
// Do not use set the height on Android versions below 11
|
||||||
if (isOlderAndroidOS) {
|
if (Platform.OS === 'android' && Platform.Version < 30) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -118,7 +108,7 @@ export function useViewPosition(viewRef: RefObject<View>, deps: React.Dependency
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [...deps, isTablet, height, viewRef, modalPosition]); // eslint-disable-line react-hooks/exhaustive-deps -- can't verify ...deps
|
}, [...deps, isTablet, height, viewRef, modalPosition]);
|
||||||
|
|
||||||
return modalPosition;
|
return modalPosition;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,302 +0,0 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
||||||
// See LICENSE.txt for license information.
|
|
||||||
|
|
||||||
import {renderHook} from '@testing-library/react-hooks';
|
|
||||||
import {Platform} from 'react-native';
|
|
||||||
|
|
||||||
import {
|
|
||||||
FLOATING_BANNER_BOTTOM_OFFSET_PHONE_IOS,
|
|
||||||
FLOATING_BANNER_BOTTOM_OFFSET_WITH_KEYBOARD_IOS,
|
|
||||||
FLOATING_BANNER_BOTTOM_OFFSET_WITH_KEYBOARD_ANDROID,
|
|
||||||
FLOATING_BANNER_TABLET_EXTRA_BOTTOM_OFFSET,
|
|
||||||
TABLET_SIDEBAR_WIDTH,
|
|
||||||
} from '@constants/view';
|
|
||||||
import * as Device from '@hooks/device';
|
|
||||||
|
|
||||||
import {useBannerGestureRootPosition, testExports} from './useBannerGestureRootPosition';
|
|
||||||
|
|
||||||
jest.mock('@hooks/device', () => ({
|
|
||||||
useIsTablet: jest.fn(),
|
|
||||||
useKeyboardHeight: jest.fn(),
|
|
||||||
useWindowDimensions: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('useBannerGestureRootPosition', () => {
|
|
||||||
const {BANNER_TABLET_WIDTH_PERCENTAGE} = testExports;
|
|
||||||
const mockUseIsTablet = jest.mocked(Device.useIsTablet);
|
|
||||||
const mockUseKeyboardHeight = jest.mocked(Device.useKeyboardHeight);
|
|
||||||
const mockUseWindowDimensions = jest.mocked(Device.useWindowDimensions);
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
mockUseIsTablet.mockReturnValue(false);
|
|
||||||
mockUseKeyboardHeight.mockReturnValue(0);
|
|
||||||
mockUseWindowDimensions.mockReturnValue({width: 375, height: 812});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('top position', () => {
|
|
||||||
it('should return correct style for top banner on phone', () => {
|
|
||||||
const {result} = renderHook(() =>
|
|
||||||
useBannerGestureRootPosition({
|
|
||||||
position: 'top',
|
|
||||||
containerHeight: 40,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.current).toEqual({
|
|
||||||
height: 40,
|
|
||||||
top: 0,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return correct style for top banner on tablet', () => {
|
|
||||||
mockUseIsTablet.mockReturnValue(true);
|
|
||||||
mockUseWindowDimensions.mockReturnValue({width: 1024, height: 768});
|
|
||||||
|
|
||||||
const {result} = renderHook(() =>
|
|
||||||
useBannerGestureRootPosition({
|
|
||||||
position: 'top',
|
|
||||||
containerHeight: 40,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const diffWidth = 1024 - TABLET_SIDEBAR_WIDTH;
|
|
||||||
const expectedMaxWidth = (BANNER_TABLET_WIDTH_PERCENTAGE / 100) * diffWidth;
|
|
||||||
|
|
||||||
expect(result.current).toEqual({
|
|
||||||
height: 40,
|
|
||||||
top: 0,
|
|
||||||
maxWidth: expectedMaxWidth,
|
|
||||||
alignSelf: 'center',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('bottom position - iOS', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
Platform.OS = 'ios';
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return correct style for bottom banner on phone without keyboard', () => {
|
|
||||||
const {result} = renderHook(() =>
|
|
||||||
useBannerGestureRootPosition({
|
|
||||||
position: 'bottom',
|
|
||||||
containerHeight: 40,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.current).toEqual({
|
|
||||||
height: 40,
|
|
||||||
bottom: FLOATING_BANNER_BOTTOM_OFFSET_PHONE_IOS,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return correct style for bottom banner on phone with keyboard', () => {
|
|
||||||
mockUseKeyboardHeight.mockReturnValue(300);
|
|
||||||
|
|
||||||
const {result} = renderHook(() =>
|
|
||||||
useBannerGestureRootPosition({
|
|
||||||
position: 'bottom',
|
|
||||||
containerHeight: 40,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.current).toEqual({
|
|
||||||
height: 40,
|
|
||||||
bottom: FLOATING_BANNER_BOTTOM_OFFSET_WITH_KEYBOARD_IOS + 300,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return correct style for bottom banner on tablet without keyboard', () => {
|
|
||||||
mockUseIsTablet.mockReturnValue(true);
|
|
||||||
mockUseWindowDimensions.mockReturnValue({width: 1024, height: 768});
|
|
||||||
|
|
||||||
const {result} = renderHook(() =>
|
|
||||||
useBannerGestureRootPosition({
|
|
||||||
position: 'bottom',
|
|
||||||
containerHeight: 40,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const diffWidth = 1024 - TABLET_SIDEBAR_WIDTH;
|
|
||||||
const expectedMaxWidth = (BANNER_TABLET_WIDTH_PERCENTAGE / 100) * diffWidth;
|
|
||||||
const expectedBottom = FLOATING_BANNER_BOTTOM_OFFSET_PHONE_IOS + FLOATING_BANNER_TABLET_EXTRA_BOTTOM_OFFSET;
|
|
||||||
|
|
||||||
expect(result.current).toEqual({
|
|
||||||
height: 40,
|
|
||||||
bottom: expectedBottom,
|
|
||||||
maxWidth: expectedMaxWidth,
|
|
||||||
alignSelf: 'center',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return correct style for bottom banner on tablet with keyboard', () => {
|
|
||||||
mockUseIsTablet.mockReturnValue(true);
|
|
||||||
mockUseKeyboardHeight.mockReturnValue(300);
|
|
||||||
mockUseWindowDimensions.mockReturnValue({width: 1024, height: 768});
|
|
||||||
|
|
||||||
const {result} = renderHook(() =>
|
|
||||||
useBannerGestureRootPosition({
|
|
||||||
position: 'bottom',
|
|
||||||
containerHeight: 40,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const diffWidth = 1024 - TABLET_SIDEBAR_WIDTH;
|
|
||||||
const expectedMaxWidth = (BANNER_TABLET_WIDTH_PERCENTAGE / 100) * diffWidth;
|
|
||||||
|
|
||||||
expect(result.current).toEqual({
|
|
||||||
height: 40,
|
|
||||||
bottom: FLOATING_BANNER_BOTTOM_OFFSET_WITH_KEYBOARD_IOS + 300,
|
|
||||||
maxWidth: expectedMaxWidth,
|
|
||||||
alignSelf: 'center',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('bottom position - Android', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
Platform.OS = 'android';
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return correct style for bottom banner on phone', () => {
|
|
||||||
const {result} = renderHook(() =>
|
|
||||||
useBannerGestureRootPosition({
|
|
||||||
position: 'bottom',
|
|
||||||
containerHeight: 40,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.current).toEqual({
|
|
||||||
height: 40,
|
|
||||||
bottom: FLOATING_BANNER_BOTTOM_OFFSET_WITH_KEYBOARD_ANDROID,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should ignore keyboard height on Android', () => {
|
|
||||||
mockUseKeyboardHeight.mockReturnValue(300);
|
|
||||||
|
|
||||||
const {result} = renderHook(() =>
|
|
||||||
useBannerGestureRootPosition({
|
|
||||||
position: 'bottom',
|
|
||||||
containerHeight: 40,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.current).toEqual({
|
|
||||||
height: 40,
|
|
||||||
bottom: FLOATING_BANNER_BOTTOM_OFFSET_WITH_KEYBOARD_ANDROID,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return correct style for bottom banner on tablet', () => {
|
|
||||||
mockUseIsTablet.mockReturnValue(true);
|
|
||||||
mockUseWindowDimensions.mockReturnValue({width: 1024, height: 768});
|
|
||||||
|
|
||||||
const {result} = renderHook(() =>
|
|
||||||
useBannerGestureRootPosition({
|
|
||||||
position: 'bottom',
|
|
||||||
containerHeight: 40,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const diffWidth = 1024 - TABLET_SIDEBAR_WIDTH;
|
|
||||||
const expectedMaxWidth = (BANNER_TABLET_WIDTH_PERCENTAGE / 100) * diffWidth;
|
|
||||||
|
|
||||||
expect(result.current).toEqual({
|
|
||||||
height: 40,
|
|
||||||
bottom: FLOATING_BANNER_BOTTOM_OFFSET_WITH_KEYBOARD_ANDROID,
|
|
||||||
maxWidth: expectedMaxWidth,
|
|
||||||
alignSelf: 'center',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('container height variations', () => {
|
|
||||||
it('should handle different container heights', () => {
|
|
||||||
const {result: result1} = renderHook(() =>
|
|
||||||
useBannerGestureRootPosition({
|
|
||||||
position: 'top',
|
|
||||||
containerHeight: 100,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result1.current.height).toBe(100);
|
|
||||||
|
|
||||||
const {result: result2} = renderHook(() =>
|
|
||||||
useBannerGestureRootPosition({
|
|
||||||
position: 'top',
|
|
||||||
containerHeight: 200,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result2.current.height).toBe(200);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('memoization', () => {
|
|
||||||
it('should memoize result when dependencies do not change', () => {
|
|
||||||
mockUseIsTablet.mockReturnValue(false);
|
|
||||||
mockUseKeyboardHeight.mockReturnValue(0);
|
|
||||||
mockUseWindowDimensions.mockReturnValue({width: 375, height: 812});
|
|
||||||
|
|
||||||
const {result, rerender} = renderHook(() =>
|
|
||||||
useBannerGestureRootPosition({
|
|
||||||
position: 'top',
|
|
||||||
containerHeight: 40,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const firstResult = result.current;
|
|
||||||
rerender();
|
|
||||||
const secondResult = result.current;
|
|
||||||
|
|
||||||
expect(firstResult).toBe(secondResult);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should recompute when keyboard height changes', () => {
|
|
||||||
Platform.OS = 'ios';
|
|
||||||
mockUseKeyboardHeight.mockReturnValue(0);
|
|
||||||
|
|
||||||
const {result, rerender} = renderHook(() =>
|
|
||||||
useBannerGestureRootPosition({
|
|
||||||
position: 'bottom',
|
|
||||||
containerHeight: 40,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const firstResult = result.current;
|
|
||||||
|
|
||||||
mockUseKeyboardHeight.mockReturnValue(300);
|
|
||||||
rerender();
|
|
||||||
const secondResult = result.current;
|
|
||||||
|
|
||||||
expect(firstResult).not.toBe(secondResult);
|
|
||||||
expect(secondResult.bottom).toBe(FLOATING_BANNER_BOTTOM_OFFSET_WITH_KEYBOARD_IOS + 300);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should recompute when tablet state changes', () => {
|
|
||||||
mockUseIsTablet.mockReturnValue(false);
|
|
||||||
mockUseWindowDimensions.mockReturnValue({width: 375, height: 812});
|
|
||||||
|
|
||||||
const {result, rerender} = renderHook(() =>
|
|
||||||
useBannerGestureRootPosition({
|
|
||||||
position: 'top',
|
|
||||||
containerHeight: 40,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const firstResult = result.current;
|
|
||||||
|
|
||||||
mockUseIsTablet.mockReturnValue(true);
|
|
||||||
mockUseWindowDimensions.mockReturnValue({width: 1024, height: 768});
|
|
||||||
rerender();
|
|
||||||
const secondResult = result.current;
|
|
||||||
|
|
||||||
expect(firstResult).not.toBe(secondResult);
|
|
||||||
expect(secondResult.maxWidth).toBeDefined();
|
|
||||||
expect(secondResult.alignSelf).toBe('center');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
||||||
// See LICENSE.txt for license information.
|
|
||||||
|
|
||||||
import {useMemo} from 'react';
|
|
||||||
import {Platform, type ViewStyle} from 'react-native';
|
|
||||||
|
|
||||||
import {
|
|
||||||
FLOATING_BANNER_BOTTOM_OFFSET_PHONE_IOS,
|
|
||||||
FLOATING_BANNER_BOTTOM_OFFSET_PHONE_ANDROID,
|
|
||||||
FLOATING_BANNER_BOTTOM_OFFSET_WITH_KEYBOARD_IOS,
|
|
||||||
FLOATING_BANNER_BOTTOM_OFFSET_WITH_KEYBOARD_ANDROID,
|
|
||||||
FLOATING_BANNER_TABLET_EXTRA_BOTTOM_OFFSET,
|
|
||||||
TABLET_SIDEBAR_WIDTH,
|
|
||||||
} from '@constants/view';
|
|
||||||
import {useIsTablet, useKeyboardHeight, useWindowDimensions} from '@hooks/device';
|
|
||||||
|
|
||||||
import type {FloatingBannerPosition} from '@components/floating_banner/types';
|
|
||||||
|
|
||||||
const BANNER_TABLET_WIDTH_PERCENTAGE = 96;
|
|
||||||
|
|
||||||
type UseBannerGestureRootPositionParams = {
|
|
||||||
position: FloatingBannerPosition;
|
|
||||||
containerHeight: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useBannerGestureRootPosition = ({position, containerHeight}: UseBannerGestureRootPositionParams): ViewStyle => {
|
|
||||||
const isTablet = useIsTablet();
|
|
||||||
const keyboardHeight = useKeyboardHeight();
|
|
||||||
const {width: windowWidth} = useWindowDimensions();
|
|
||||||
const isTop = position === 'top';
|
|
||||||
const baseBottomOffset = Platform.OS === 'android' ? FLOATING_BANNER_BOTTOM_OFFSET_PHONE_ANDROID : FLOATING_BANNER_BOTTOM_OFFSET_PHONE_IOS;
|
|
||||||
const bottomOffset = isTablet ? (baseBottomOffset + FLOATING_BANNER_TABLET_EXTRA_BOTTOM_OFFSET) : baseBottomOffset;
|
|
||||||
|
|
||||||
return useMemo(() => {
|
|
||||||
const baseStyle: ViewStyle = {height: containerHeight};
|
|
||||||
|
|
||||||
if (isTablet) {
|
|
||||||
const diffWidth = windowWidth - TABLET_SIDEBAR_WIDTH;
|
|
||||||
const tabletWidth = (BANNER_TABLET_WIDTH_PERCENTAGE / 100) * diffWidth;
|
|
||||||
baseStyle.maxWidth = tabletWidth;
|
|
||||||
baseStyle.alignSelf = 'center';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isTop) {
|
|
||||||
return {...baseStyle, top: 0};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Platform.OS === 'android') {
|
|
||||||
return {
|
|
||||||
...baseStyle,
|
|
||||||
bottom: FLOATING_BANNER_BOTTOM_OFFSET_WITH_KEYBOARD_ANDROID,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...baseStyle,
|
|
||||||
bottom: (keyboardHeight > 0 ? FLOATING_BANNER_BOTTOM_OFFSET_WITH_KEYBOARD_IOS : bottomOffset) + keyboardHeight,
|
|
||||||
};
|
|
||||||
}, [isTop, keyboardHeight, bottomOffset, containerHeight, isTablet, windowWidth]);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const testExports = {
|
|
||||||
BANNER_TABLET_WIDTH_PERCENTAGE,
|
|
||||||
};
|
|
||||||
|
|
@ -3,13 +3,11 @@
|
||||||
|
|
||||||
import {CallsManager} from '@calls/calls_manager';
|
import {CallsManager} from '@calls/calls_manager';
|
||||||
import DatabaseManager from '@database/manager';
|
import DatabaseManager from '@database/manager';
|
||||||
import {getAllServerCredentials, getActiveServerUrl} from '@init/credentials';
|
import {getAllServerCredentials} from '@init/credentials';
|
||||||
import {initialLaunch} from '@init/launch';
|
import {initialLaunch} from '@init/launch';
|
||||||
import ManagedApp from '@init/managed_app';
|
import ManagedApp from '@init/managed_app';
|
||||||
import PushNotifications from '@init/push_notifications';
|
import PushNotifications from '@init/push_notifications';
|
||||||
import GlobalEventHandler from '@managers/global_event_handler';
|
import GlobalEventHandler from '@managers/global_event_handler';
|
||||||
import NetworkConnectivityManager from '@managers/network_connectivity_manager';
|
|
||||||
import NetworkConnectivitySubscriptionManager from '@managers/network_connectivity_subscription_manager';
|
|
||||||
import NetworkManager from '@managers/network_manager';
|
import NetworkManager from '@managers/network_manager';
|
||||||
import SessionManager from '@managers/session_manager';
|
import SessionManager from '@managers/session_manager';
|
||||||
import WebsocketManager from '@managers/websocket_manager';
|
import WebsocketManager from '@managers/websocket_manager';
|
||||||
|
|
@ -51,9 +49,6 @@ export async function initialize() {
|
||||||
ManagedApp.init();
|
ManagedApp.init();
|
||||||
SessionManager.init();
|
SessionManager.init();
|
||||||
CallsManager.initialize();
|
CallsManager.initialize();
|
||||||
|
|
||||||
const activeServerUrl = await getActiveServerUrl();
|
|
||||||
NetworkConnectivityManager.init(activeServerUrl || null);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -72,8 +67,5 @@ export async function start() {
|
||||||
|
|
||||||
await WebsocketManager.init(serverCredentials);
|
await WebsocketManager.init(serverCredentials);
|
||||||
|
|
||||||
NetworkConnectivitySubscriptionManager.init();
|
|
||||||
|
|
||||||
initialLaunch();
|
initialLaunch();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -53,15 +53,7 @@ jest.mock('@database/manager', () => ({
|
||||||
serverDatabases: {},
|
serverDatabases: {},
|
||||||
}));
|
}));
|
||||||
jest.mock('@init/credentials');
|
jest.mock('@init/credentials');
|
||||||
jest.mock('@queries/app/global', () => {
|
jest.mock('@queries/app/global');
|
||||||
const {of: mockOf} = require('rxjs');
|
|
||||||
return {
|
|
||||||
getLastViewedChannelIdAndServer: jest.fn(),
|
|
||||||
getOnboardingViewed: jest.fn(),
|
|
||||||
getLastViewedThreadIdAndServer: jest.fn(),
|
|
||||||
observeLowConnectivityMonitor: jest.fn(() => mockOf(true)),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
jest.mock('@queries/app/servers');
|
jest.mock('@queries/app/servers');
|
||||||
jest.mock('@queries/servers/post');
|
jest.mock('@queries/servers/post');
|
||||||
jest.mock('@queries/servers/preference');
|
jest.mock('@queries/servers/preference');
|
||||||
|
|
|
||||||
|
|
@ -1,693 +0,0 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
||||||
// See LICENSE.txt for license information.
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import {Navigation} from 'react-native-navigation';
|
|
||||||
|
|
||||||
import {Screens} from '@constants';
|
|
||||||
import {showOverlay, dismissOverlay} from '@screens/navigation';
|
|
||||||
|
|
||||||
import {BannerManager, testExports} from './banner_manager';
|
|
||||||
|
|
||||||
import type {FloatingBannerConfig} from '@components/floating_banner/types';
|
|
||||||
|
|
||||||
interface MockOverlayProps {
|
|
||||||
banners: FloatingBannerConfig[];
|
|
||||||
onDismiss: (id: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
jest.mock('react-native-navigation', () => ({
|
|
||||||
Navigation: {
|
|
||||||
updateProps: jest.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('@screens/navigation', () => ({
|
|
||||||
showOverlay: jest.fn(),
|
|
||||||
dismissOverlay: jest.fn().mockResolvedValue(undefined),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('@utils/datetime', () => ({
|
|
||||||
toMilliseconds: jest.fn().mockImplementation(({seconds}) => seconds * 1000),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockShowOverlay = jest.mocked(showOverlay);
|
|
||||||
const mockDismissOverlay = jest.mocked(dismissOverlay);
|
|
||||||
const mockUpdateProps = jest.mocked(Navigation.updateProps);
|
|
||||||
|
|
||||||
describe('BannerManager', () => {
|
|
||||||
let timeoutId = 0;
|
|
||||||
type TimeoutCallback = () => void;
|
|
||||||
const timeoutCallbacks = new Map<number, TimeoutCallback>();
|
|
||||||
|
|
||||||
const mockSetTimeout = jest.spyOn(global, 'setTimeout').mockImplementation((cb: TimeoutCallback) => {
|
|
||||||
timeoutId++;
|
|
||||||
timeoutCallbacks.set(timeoutId, cb);
|
|
||||||
return timeoutId as unknown as NodeJS.Timeout;
|
|
||||||
});
|
|
||||||
|
|
||||||
const mockClearTimeout = jest.spyOn(global, 'clearTimeout').mockImplementation((id: NodeJS.Timeout) => {
|
|
||||||
timeoutCallbacks.delete(id as unknown as number);
|
|
||||||
});
|
|
||||||
|
|
||||||
const runAllTimers = () => {
|
|
||||||
const callbacks = Array.from(timeoutCallbacks.values());
|
|
||||||
timeoutCallbacks.clear();
|
|
||||||
callbacks.forEach((cb) => cb());
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
timeoutCallbacks.clear();
|
|
||||||
timeoutId = 0;
|
|
||||||
BannerManager.cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
timeoutCallbacks.clear();
|
|
||||||
BannerManager.cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('singleton pattern', () => {
|
|
||||||
it('should export the same instance', () => {
|
|
||||||
expect(BannerManager).toBeDefined();
|
|
||||||
expect(BannerManager).toBe(BannerManager);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should allow creating new instances via testExports', () => {
|
|
||||||
const newInstance = new testExports.BannerManager();
|
|
||||||
expect(newInstance).toBeDefined();
|
|
||||||
expect(newInstance).not.toBe(BannerManager);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('showBanner', () => {
|
|
||||||
const mockFloatingBannerConfig: FloatingBannerConfig = {
|
|
||||||
id: 'test-banner',
|
|
||||||
title: 'Test Title',
|
|
||||||
message: 'Test Message',
|
|
||||||
type: 'info',
|
|
||||||
};
|
|
||||||
|
|
||||||
it('should show banner with correct overlay configuration', async () => {
|
|
||||||
BannerManager.showBanner(mockFloatingBannerConfig);
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
expect(mockShowOverlay).toHaveBeenCalledWith(
|
|
||||||
Screens.FLOATING_BANNER,
|
|
||||||
{
|
|
||||||
banners: [expect.objectContaining({
|
|
||||||
id: 'test-banner',
|
|
||||||
title: 'Test Title',
|
|
||||||
message: 'Test Message',
|
|
||||||
type: 'info',
|
|
||||||
})],
|
|
||||||
onDismiss: expect.any(Function),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
overlay: {
|
|
||||||
interceptTouchOutside: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'floating-banner-overlay',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should show banner overlay when showing banner', async () => {
|
|
||||||
BannerManager.showBanner(mockFloatingBannerConfig);
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
expect(mockShowOverlay).toHaveBeenCalledWith(
|
|
||||||
'FloatingBanner',
|
|
||||||
expect.objectContaining({
|
|
||||||
banners: expect.arrayContaining([
|
|
||||||
expect.objectContaining({id: 'test-banner'}),
|
|
||||||
]),
|
|
||||||
}),
|
|
||||||
expect.any(Object),
|
|
||||||
'floating-banner-overlay',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should show banner without title and message when using customComponent', async () => {
|
|
||||||
const bannerWithCustomComponent: FloatingBannerConfig = {
|
|
||||||
id: 'custom-banner',
|
|
||||||
customComponent: React.createElement('div', {testID: 'custom-content'}, 'Custom Content'),
|
|
||||||
};
|
|
||||||
|
|
||||||
BannerManager.showBanner(bannerWithCustomComponent);
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
expect(mockShowOverlay).toHaveBeenCalledWith(
|
|
||||||
Screens.FLOATING_BANNER,
|
|
||||||
expect.objectContaining({
|
|
||||||
banners: [expect.objectContaining({
|
|
||||||
id: 'custom-banner',
|
|
||||||
customComponent: expect.anything(),
|
|
||||||
})],
|
|
||||||
}),
|
|
||||||
expect.anything(),
|
|
||||||
'floating-banner-overlay',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should stack multiple banners and update overlay', async () => {
|
|
||||||
const firstBanner: FloatingBannerConfig = {
|
|
||||||
id: 'first-banner',
|
|
||||||
title: 'First',
|
|
||||||
message: 'First message',
|
|
||||||
};
|
|
||||||
|
|
||||||
const secondBanner: FloatingBannerConfig = {
|
|
||||||
id: 'second-banner',
|
|
||||||
title: 'Second',
|
|
||||||
message: 'Second message',
|
|
||||||
};
|
|
||||||
|
|
||||||
BannerManager.showBanner(firstBanner);
|
|
||||||
await Promise.resolve();
|
|
||||||
expect(mockShowOverlay).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
BannerManager.showBanner(secondBanner);
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
expect(mockUpdateProps).toHaveBeenCalledWith(
|
|
||||||
'floating-banner-overlay',
|
|
||||||
expect.objectContaining({
|
|
||||||
banners: expect.arrayContaining([
|
|
||||||
expect.objectContaining({id: 'first-banner'}),
|
|
||||||
expect.objectContaining({id: 'second-banner'}),
|
|
||||||
]),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle custom content with React elements', async () => {
|
|
||||||
const customElement = React.createElement('div', {}, 'Custom content');
|
|
||||||
const bannerWithCustomComponent: FloatingBannerConfig = {
|
|
||||||
...mockFloatingBannerConfig,
|
|
||||||
customComponent: customElement,
|
|
||||||
};
|
|
||||||
|
|
||||||
jest.spyOn(React, 'isValidElement').mockReturnValue(true);
|
|
||||||
jest.spyOn(React, 'cloneElement').mockReturnValue(customElement);
|
|
||||||
|
|
||||||
BannerManager.showBanner(bannerWithCustomComponent);
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
expect(React.cloneElement).toHaveBeenCalledWith(
|
|
||||||
customElement,
|
|
||||||
{
|
|
||||||
onDismiss: expect.any(Function),
|
|
||||||
dismissible: undefined,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call cloned onDismiss handler when custom component is dismissed', async () => {
|
|
||||||
const mockOnDismiss = jest.fn();
|
|
||||||
const customElement = React.createElement('div', {}, 'Custom content');
|
|
||||||
const bannerWithCustomComponent: FloatingBannerConfig = {
|
|
||||||
id: 'custom-component-banner',
|
|
||||||
customComponent: customElement,
|
|
||||||
dismissible: true,
|
|
||||||
onDismiss: mockOnDismiss,
|
|
||||||
};
|
|
||||||
|
|
||||||
jest.spyOn(React, 'isValidElement').mockReturnValue(true);
|
|
||||||
|
|
||||||
BannerManager.showBanner(bannerWithCustomComponent);
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
const cloneCall = (React.cloneElement as jest.Mock).mock.calls[0];
|
|
||||||
const clonedProps = cloneCall[1];
|
|
||||||
const clonedOnDismiss = clonedProps.onDismiss;
|
|
||||||
|
|
||||||
clonedOnDismiss();
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
expect(mockOnDismiss).toHaveBeenCalled();
|
|
||||||
|
|
||||||
runAllTimers();
|
|
||||||
await Promise.resolve();
|
|
||||||
runAllTimers();
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
expect(mockDismissOverlay).toHaveBeenCalledWith('floating-banner-overlay');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should respect dismissible property from banner config', async () => {
|
|
||||||
const customElement = React.createElement('div', {}, 'Custom content');
|
|
||||||
|
|
||||||
const dismissibleBanner: FloatingBannerConfig = {
|
|
||||||
...mockFloatingBannerConfig,
|
|
||||||
customComponent: customElement,
|
|
||||||
dismissible: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const nonDismissibleBanner: FloatingBannerConfig = {
|
|
||||||
...mockFloatingBannerConfig,
|
|
||||||
customComponent: customElement,
|
|
||||||
dismissible: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
jest.spyOn(React, 'isValidElement').mockReturnValue(true);
|
|
||||||
jest.spyOn(React, 'cloneElement').mockReturnValue(customElement);
|
|
||||||
|
|
||||||
BannerManager.showBanner(dismissibleBanner);
|
|
||||||
await Promise.resolve();
|
|
||||||
expect(React.cloneElement).toHaveBeenCalledWith(
|
|
||||||
customElement,
|
|
||||||
{
|
|
||||||
onDismiss: expect.any(Function),
|
|
||||||
dismissible: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
jest.clearAllMocks();
|
|
||||||
|
|
||||||
BannerManager.showBanner(nonDismissibleBanner);
|
|
||||||
await Promise.resolve();
|
|
||||||
expect(React.cloneElement).toHaveBeenCalledWith(
|
|
||||||
customElement,
|
|
||||||
{
|
|
||||||
onDismiss: expect.any(Function),
|
|
||||||
dismissible: false,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call original onDismiss when banner is dismissed via handleDismiss', async () => {
|
|
||||||
const mockOnDismiss = jest.fn();
|
|
||||||
const bannerWithOnDismiss: FloatingBannerConfig = {
|
|
||||||
...mockFloatingBannerConfig,
|
|
||||||
onDismiss: mockOnDismiss,
|
|
||||||
};
|
|
||||||
|
|
||||||
BannerManager.showBanner(bannerWithOnDismiss);
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
const overlayCall = mockShowOverlay.mock.calls[0];
|
|
||||||
const overlayOnDismiss = (overlayCall?.[1] as MockOverlayProps)?.onDismiss;
|
|
||||||
|
|
||||||
overlayOnDismiss?.('test-banner');
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
expect(mockOnDismiss).toHaveBeenCalled();
|
|
||||||
|
|
||||||
runAllTimers();
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
expect(mockDismissOverlay).toHaveBeenCalledWith('floating-banner-overlay');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should remove banner from list when dismissed but keep overlay for other banners', async () => {
|
|
||||||
const firstBanner: FloatingBannerConfig = {
|
|
||||||
id: 'first-banner',
|
|
||||||
title: 'First',
|
|
||||||
message: 'First message',
|
|
||||||
onDismiss: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const secondBanner: FloatingBannerConfig = {
|
|
||||||
id: 'second-banner',
|
|
||||||
title: 'Second',
|
|
||||||
message: 'Second message',
|
|
||||||
};
|
|
||||||
|
|
||||||
BannerManager.showBanner(firstBanner);
|
|
||||||
await Promise.resolve();
|
|
||||||
BannerManager.showBanner(secondBanner);
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
const overlayCall = mockUpdateProps.mock.calls[0];
|
|
||||||
const overlayOnDismiss = (overlayCall?.[1] as MockOverlayProps)?.onDismiss;
|
|
||||||
|
|
||||||
overlayOnDismiss?.('first-banner');
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
expect(firstBanner.onDismiss).toHaveBeenCalled();
|
|
||||||
expect(mockUpdateProps).toHaveBeenCalledWith(
|
|
||||||
'floating-banner-overlay',
|
|
||||||
expect.objectContaining({
|
|
||||||
banners: expect.arrayContaining([
|
|
||||||
expect.objectContaining({id: 'second-banner'}),
|
|
||||||
]),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(mockDismissOverlay).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not call onDismiss for different banner IDs in overlay callback', () => {
|
|
||||||
const mockOnDismiss = jest.fn();
|
|
||||||
const bannerWithOnDismiss: FloatingBannerConfig = {
|
|
||||||
...mockFloatingBannerConfig,
|
|
||||||
onDismiss: mockOnDismiss,
|
|
||||||
};
|
|
||||||
|
|
||||||
BannerManager.showBanner(bannerWithOnDismiss);
|
|
||||||
|
|
||||||
const overlayCall = mockShowOverlay.mock.calls[0];
|
|
||||||
const overlayOnDismiss = (overlayCall?.[1] as MockOverlayProps)?.onDismiss;
|
|
||||||
|
|
||||||
overlayOnDismiss?.('different-banner-id');
|
|
||||||
|
|
||||||
expect(mockOnDismiss).not.toHaveBeenCalled();
|
|
||||||
expect(mockDismissOverlay).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle errors in onDismiss callback gracefully', async () => {
|
|
||||||
const mockOnDismiss = jest.fn().mockImplementation(() => {
|
|
||||||
throw new Error('Test error');
|
|
||||||
});
|
|
||||||
const bannerWithErrorOnDismiss: FloatingBannerConfig = {
|
|
||||||
...mockFloatingBannerConfig,
|
|
||||||
onDismiss: mockOnDismiss,
|
|
||||||
};
|
|
||||||
|
|
||||||
BannerManager.showBanner(bannerWithErrorOnDismiss);
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
const overlayCall = mockShowOverlay.mock.calls[0];
|
|
||||||
const overlayOnDismiss = (overlayCall?.[1] as MockOverlayProps)?.onDismiss;
|
|
||||||
|
|
||||||
expect(() => overlayOnDismiss?.('test-banner')).not.toThrow();
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
runAllTimers();
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
expect(mockDismissOverlay).toHaveBeenCalledWith('floating-banner-overlay');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('showBannerWithAutoHide', () => {
|
|
||||||
const mockFloatingBannerConfig: FloatingBannerConfig = {
|
|
||||||
id: 'auto-hide-banner',
|
|
||||||
title: 'Auto Hide Banner',
|
|
||||||
message: 'This will auto hide',
|
|
||||||
};
|
|
||||||
|
|
||||||
it('should show banner and set timeout for auto hide with default duration', async () => {
|
|
||||||
BannerManager.showBannerWithAutoHide(mockFloatingBannerConfig);
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
expect(mockShowOverlay).toHaveBeenCalled();
|
|
||||||
expect(mockSetTimeout).toHaveBeenCalledWith(expect.any(Function), 5000);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should show banner and set timeout for auto hide with custom duration', async () => {
|
|
||||||
const customDuration = 3000;
|
|
||||||
|
|
||||||
BannerManager.showBannerWithAutoHide(mockFloatingBannerConfig, customDuration);
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
expect(mockShowOverlay).toHaveBeenCalled();
|
|
||||||
expect(mockSetTimeout).toHaveBeenCalledWith(expect.any(Function), customDuration);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should hide banner when timeout executes', async () => {
|
|
||||||
BannerManager.showBannerWithAutoHide(mockFloatingBannerConfig);
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
expect(mockShowOverlay).toHaveBeenCalled();
|
|
||||||
|
|
||||||
runAllTimers();
|
|
||||||
await Promise.resolve();
|
|
||||||
runAllTimers();
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
expect(mockDismissOverlay).toHaveBeenCalledWith('floating-banner-overlay');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('hideBanner', () => {
|
|
||||||
const mockFloatingBannerConfig: FloatingBannerConfig = {
|
|
||||||
id: 'hide-test-banner',
|
|
||||||
title: 'Hide Test',
|
|
||||||
message: 'Test hiding',
|
|
||||||
};
|
|
||||||
|
|
||||||
it('should hide visible banner and clear timeouts', async () => {
|
|
||||||
BannerManager.showBanner(mockFloatingBannerConfig);
|
|
||||||
await Promise.resolve();
|
|
||||||
expect(mockShowOverlay).toHaveBeenCalled();
|
|
||||||
|
|
||||||
BannerManager.hideBanner('hide-test-banner');
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
runAllTimers();
|
|
||||||
await Promise.resolve();
|
|
||||||
runAllTimers();
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
expect(mockDismissOverlay).toHaveBeenCalledWith('floating-banner-overlay');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should hide specific banner by ID', async () => {
|
|
||||||
const firstBanner: FloatingBannerConfig = {
|
|
||||||
id: 'first-banner',
|
|
||||||
title: 'First',
|
|
||||||
message: 'First message',
|
|
||||||
};
|
|
||||||
|
|
||||||
const secondBanner: FloatingBannerConfig = {
|
|
||||||
id: 'second-banner',
|
|
||||||
title: 'Second',
|
|
||||||
message: 'Second message',
|
|
||||||
};
|
|
||||||
|
|
||||||
BannerManager.showBanner(firstBanner);
|
|
||||||
BannerManager.showBanner(secondBanner);
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
BannerManager.hideBanner('first-banner');
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
expect(mockUpdateProps).toHaveBeenCalledWith(
|
|
||||||
'floating-banner-overlay',
|
|
||||||
expect.objectContaining({
|
|
||||||
banners: expect.arrayContaining([
|
|
||||||
expect.objectContaining({id: 'second-banner'}),
|
|
||||||
]),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(mockDismissOverlay).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call current onDismiss callback when hiding', () => {
|
|
||||||
const mockOnDismiss = jest.fn();
|
|
||||||
const bannerWithOnDismiss: FloatingBannerConfig = {
|
|
||||||
...mockFloatingBannerConfig,
|
|
||||||
onDismiss: mockOnDismiss,
|
|
||||||
};
|
|
||||||
|
|
||||||
BannerManager.showBanner(bannerWithOnDismiss);
|
|
||||||
BannerManager.hideBanner('hide-test-banner');
|
|
||||||
|
|
||||||
expect(mockOnDismiss).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle errors in onDismiss callback during hide', async () => {
|
|
||||||
const mockOnDismiss = jest.fn().mockImplementation(() => {
|
|
||||||
throw new Error('Hide error');
|
|
||||||
});
|
|
||||||
const bannerWithErrorOnDismiss: FloatingBannerConfig = {
|
|
||||||
...mockFloatingBannerConfig,
|
|
||||||
onDismiss: mockOnDismiss,
|
|
||||||
};
|
|
||||||
|
|
||||||
BannerManager.showBanner(bannerWithErrorOnDismiss);
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
expect(() => BannerManager.hideBanner('hide-test-banner')).not.toThrow();
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
runAllTimers();
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
expect(mockOnDismiss).toHaveBeenCalled();
|
|
||||||
expect(mockDismissOverlay).toHaveBeenCalledWith('floating-banner-overlay');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('hideAllBanners', () => {
|
|
||||||
it('should hide all banners at once', async () => {
|
|
||||||
const firstBanner: FloatingBannerConfig = {
|
|
||||||
id: 'first-banner',
|
|
||||||
title: 'First',
|
|
||||||
message: 'First message',
|
|
||||||
};
|
|
||||||
|
|
||||||
const secondBanner: FloatingBannerConfig = {
|
|
||||||
id: 'second-banner',
|
|
||||||
title: 'Second',
|
|
||||||
message: 'Second message',
|
|
||||||
};
|
|
||||||
|
|
||||||
const thirdBanner: FloatingBannerConfig = {
|
|
||||||
id: 'third-banner',
|
|
||||||
title: 'Third',
|
|
||||||
message: 'Third message',
|
|
||||||
};
|
|
||||||
|
|
||||||
BannerManager.showBanner(firstBanner);
|
|
||||||
BannerManager.showBanner(secondBanner);
|
|
||||||
BannerManager.showBanner(thirdBanner);
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
expect(mockShowOverlay).toHaveBeenCalled();
|
|
||||||
|
|
||||||
BannerManager.hideAllBanners();
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
runAllTimers();
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
expect(mockDismissOverlay).toHaveBeenCalledWith('floating-banner-overlay');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should clear all auto-hide timers when hiding all banners', async () => {
|
|
||||||
const firstBanner: FloatingBannerConfig = {
|
|
||||||
id: 'first-auto-hide',
|
|
||||||
title: 'First Auto Hide',
|
|
||||||
message: 'First message',
|
|
||||||
};
|
|
||||||
|
|
||||||
const secondBanner: FloatingBannerConfig = {
|
|
||||||
id: 'second-auto-hide',
|
|
||||||
title: 'Second Auto Hide',
|
|
||||||
message: 'Second message',
|
|
||||||
};
|
|
||||||
|
|
||||||
BannerManager.showBannerWithAutoHide(firstBanner, 3000);
|
|
||||||
BannerManager.showBannerWithAutoHide(secondBanner, 5000);
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
expect(mockShowOverlay).toHaveBeenCalled();
|
|
||||||
|
|
||||||
const clearTimeoutCallsBefore = mockClearTimeout.mock.calls.length;
|
|
||||||
BannerManager.hideAllBanners();
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
expect(mockClearTimeout.mock.calls.length).toBeGreaterThan(clearTimeoutCallsBefore);
|
|
||||||
|
|
||||||
runAllTimers();
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
expect(mockDismissOverlay).toHaveBeenCalledWith('floating-banner-overlay');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should do nothing when no banners are visible', () => {
|
|
||||||
BannerManager.hideAllBanners();
|
|
||||||
|
|
||||||
expect(mockDismissOverlay).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('edge cases', () => {
|
|
||||||
it('should set empty banners array when hiding last banner', async () => {
|
|
||||||
const banner: FloatingBannerConfig = {
|
|
||||||
id: 'single-banner',
|
|
||||||
title: 'Single',
|
|
||||||
message: 'Single banner',
|
|
||||||
};
|
|
||||||
|
|
||||||
BannerManager.showBanner(banner);
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
expect(mockShowOverlay).toHaveBeenCalled();
|
|
||||||
|
|
||||||
BannerManager.hideBanner('single-banner');
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
expect(mockUpdateProps).toHaveBeenCalledWith(
|
|
||||||
'floating-banner-overlay',
|
|
||||||
expect.objectContaining({
|
|
||||||
banners: [],
|
|
||||||
onDismiss: expect.any(Function),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const updatePropsCall = mockUpdateProps.mock.calls[mockUpdateProps.mock.calls.length - 1];
|
|
||||||
const emptyOnDismiss = (updatePropsCall?.[1] as MockOverlayProps)?.onDismiss;
|
|
||||||
|
|
||||||
expect(() => emptyOnDismiss?.('non-existent-banner')).not.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('dismiss delay behavior', () => {
|
|
||||||
it('should cancel overlay dismiss when new banner is added during delay', async () => {
|
|
||||||
const firstBanner: FloatingBannerConfig = {
|
|
||||||
id: 'first-banner',
|
|
||||||
title: 'First',
|
|
||||||
message: 'First message',
|
|
||||||
};
|
|
||||||
|
|
||||||
const secondBanner: FloatingBannerConfig = {
|
|
||||||
id: 'second-banner',
|
|
||||||
title: 'Second',
|
|
||||||
message: 'Second message',
|
|
||||||
};
|
|
||||||
|
|
||||||
BannerManager.showBanner(firstBanner);
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
const dismissCallsBefore = mockDismissOverlay.mock.calls.length;
|
|
||||||
|
|
||||||
BannerManager.hideBanner('first-banner');
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
runAllTimers();
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
BannerManager.showBanner(secondBanner);
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
runAllTimers();
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
expect(mockDismissOverlay).toHaveBeenCalledTimes(dismissCallsBefore);
|
|
||||||
expect(mockShowOverlay).toHaveBeenLastCalledWith(
|
|
||||||
'FloatingBanner',
|
|
||||||
expect.objectContaining({
|
|
||||||
banners: expect.arrayContaining([
|
|
||||||
expect.objectContaining({id: 'second-banner'}),
|
|
||||||
]),
|
|
||||||
}),
|
|
||||||
expect.any(Object),
|
|
||||||
'floating-banner-overlay',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('cleanup', () => {
|
|
||||||
const mockFloatingBannerConfig: FloatingBannerConfig = {
|
|
||||||
id: 'cleanup-test-banner',
|
|
||||||
title: 'Cleanup Test',
|
|
||||||
message: 'Test cleanup',
|
|
||||||
};
|
|
||||||
|
|
||||||
it('should clear all timeouts and hide banner', async () => {
|
|
||||||
BannerManager.showBannerWithAutoHide(mockFloatingBannerConfig);
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
const clearTimeoutCallsBefore = mockClearTimeout.mock.calls.length;
|
|
||||||
BannerManager.cleanup();
|
|
||||||
|
|
||||||
expect(mockClearTimeout.mock.calls.length).toBeGreaterThan(clearTimeoutCallsBefore);
|
|
||||||
expect(mockDismissOverlay).toHaveBeenCalledWith('floating-banner-overlay');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reset manager state', async () => {
|
|
||||||
BannerManager.showBanner(mockFloatingBannerConfig);
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
expect(mockShowOverlay).toHaveBeenCalled();
|
|
||||||
|
|
||||||
BannerManager.cleanup();
|
|
||||||
|
|
||||||
expect(mockDismissOverlay).toHaveBeenCalledWith('floating-banner-overlay');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,263 +0,0 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
||||||
// See LICENSE.txt for license information.
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import {Navigation} from 'react-native-navigation';
|
|
||||||
|
|
||||||
import {Screens} from '@constants';
|
|
||||||
import {showOverlay, dismissOverlay} from '@screens/navigation';
|
|
||||||
import {toMilliseconds} from '@utils/datetime';
|
|
||||||
|
|
||||||
import type {FloatingBannerConfig} from '@components/floating_banner/types';
|
|
||||||
|
|
||||||
const FLOATING_BANNER_OVERLAY_ID = 'floating-banner-overlay';
|
|
||||||
const BANNER_DEFAULT_TIME_TO_HIDE = toMilliseconds({seconds: 5});
|
|
||||||
const OVERLAY_DISMISS_DELAY = toMilliseconds({seconds: 2});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* BannerManager - Singleton for managing floating banner overlays
|
|
||||||
*
|
|
||||||
* This manager handles displaying banners at the top or bottom of the screen using
|
|
||||||
* React Native Navigation overlays. It supports stacking multiple banners and manages
|
|
||||||
* their lifecycle including auto-hide timers and user dismissals.
|
|
||||||
*
|
|
||||||
* **Architecture:**
|
|
||||||
* - Uses a single overlay component that renders separate top and bottom banner sections
|
|
||||||
* - Each section (top/bottom) has its own GestureHandlerRootView for independent gesture handling
|
|
||||||
* - Supports both top and bottom positioned banners simultaneously without blocking screen interaction
|
|
||||||
* - Employs a promise-chain queue to handle rapid successive banner updates
|
|
||||||
* - Implements a 2-second delay before dismissing the overlay when the last banner is removed
|
|
||||||
*
|
|
||||||
* **Android Gesture Handling Limitation:**
|
|
||||||
* On Android, when both top AND bottom banners are displayed simultaneously, only ONE of the
|
|
||||||
* GestureHandlerRootView instances can properly register touch events. This is a known limitation
|
|
||||||
* with React Native Gesture Handler on Android when multiple gesture root views exist in overlays.
|
|
||||||
*
|
|
||||||
* **Current Behavior:**
|
|
||||||
* - iOS: Top and bottom banners work correctly together with independent gesture handling
|
|
||||||
* - Android: If both top and bottom banners appear simultaneously, gestures may not work on one section
|
|
||||||
*
|
|
||||||
* **Workaround (Not Yet Implemented):**
|
|
||||||
* To fully support Android, the implementation should:
|
|
||||||
* 1. Detect when both top and bottom banners exist on Android
|
|
||||||
* 2. Prioritize showing banners from one position (e.g., top takes precedence)
|
|
||||||
* 3. Queue banners from the other position until the primary position is clear
|
|
||||||
*
|
|
||||||
* **Previous iOS-Only Solution:**
|
|
||||||
* The implementation creates TWO separate GestureHandlerRootView instances - one for top
|
|
||||||
* banners and one for bottom banners. Each is positioned and sized only for its banner content
|
|
||||||
* using `useBannerGestureRootPosition` hook. On iOS this allows:
|
|
||||||
* - Top and bottom banners to coexist without blocking the middle of the screen
|
|
||||||
* - User interactions with app content between the banners
|
|
||||||
* - Each banner section to handle gestures independently
|
|
||||||
* - `pointerEvents='box-none'` on each GestureHandlerRootView allows touches to pass through
|
|
||||||
* non-banner areas to the content underneath
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* // Show a simple banner
|
|
||||||
* BannerManager.showBanner({
|
|
||||||
* id: 'network-error',
|
|
||||||
* title: 'Connection Lost',
|
|
||||||
* message: 'Reconnecting...',
|
|
||||||
* type: 'error',
|
|
||||||
* position: 'top'
|
|
||||||
* });
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* // Show multiple banners (they will stack)
|
|
||||||
* BannerManager.showBanner({id: 'banner1', position: 'top', ...});
|
|
||||||
* BannerManager.showBanner({id: 'banner2', position: 'bottom', ...});
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* // Show with auto-hide
|
|
||||||
* BannerManager.showBannerWithAutoHide({
|
|
||||||
* id: 'success',
|
|
||||||
* message: 'Changes saved',
|
|
||||||
* type: 'success'
|
|
||||||
* }, 3000);
|
|
||||||
*/
|
|
||||||
class BannerManagerSingleton {
|
|
||||||
private activeBanners: FloatingBannerConfig[] = [];
|
|
||||||
private overlayVisible = false;
|
|
||||||
private autoHideTimers: Map<string, NodeJS.Timeout> = new Map();
|
|
||||||
private updateQueue: Promise<void> = Promise.resolve();
|
|
||||||
private dismissOverlayTimer: NodeJS.Timeout | null = null;
|
|
||||||
private dismissOverlayResolve: (() => void) | null = null;
|
|
||||||
|
|
||||||
private clearBannerTimer(bannerId: string) {
|
|
||||||
const timer = this.autoHideTimers.get(bannerId);
|
|
||||||
if (timer) {
|
|
||||||
clearTimeout(timer);
|
|
||||||
this.autoHideTimers.delete(bannerId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private clearAllTimers() {
|
|
||||||
this.autoHideTimers.forEach((timer) => clearTimeout(timer));
|
|
||||||
this.autoHideTimers.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
private cancelDismissOverlay() {
|
|
||||||
if (this.dismissOverlayTimer) {
|
|
||||||
clearTimeout(this.dismissOverlayTimer);
|
|
||||||
this.dismissOverlayTimer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve the pending Promise to unblock the queue
|
|
||||||
if (this.dismissOverlayResolve) {
|
|
||||||
this.dismissOverlayResolve();
|
|
||||||
this.dismissOverlayResolve = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private removeBannerFromList(bannerId: string) {
|
|
||||||
const bannerIndex = this.activeBanners.findIndex((b) => b.id === bannerId);
|
|
||||||
if (bannerIndex >= 0) {
|
|
||||||
const banner = this.activeBanners[bannerIndex];
|
|
||||||
this.activeBanners.splice(bannerIndex, 1);
|
|
||||||
|
|
||||||
// Clear any auto-hide timer for this banner
|
|
||||||
this.clearBannerTimer(bannerId);
|
|
||||||
|
|
||||||
// Invoke onDismiss callback if it exists
|
|
||||||
if (banner.onDismiss) {
|
|
||||||
try {
|
|
||||||
banner.onDismiss();
|
|
||||||
} catch {
|
|
||||||
// Silent catch to ensure cleanup still runs
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateOverlay() {
|
|
||||||
this.updateQueue = this.updateQueue.then(async () => {
|
|
||||||
if (!this.activeBanners.length) {
|
|
||||||
if (this.overlayVisible) {
|
|
||||||
Navigation.updateProps(FLOATING_BANNER_OVERLAY_ID, {
|
|
||||||
banners: [],
|
|
||||||
onDismiss: () => {
|
|
||||||
// No-op: no banners to dismiss
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
this.dismissOverlayResolve = resolve;
|
|
||||||
this.dismissOverlayTimer = setTimeout(() => {
|
|
||||||
this.dismissOverlayResolve = null;
|
|
||||||
resolve();
|
|
||||||
}, OVERLAY_DISMISS_DELAY);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!this.activeBanners.length && this.overlayVisible) {
|
|
||||||
await dismissOverlay(FLOATING_BANNER_OVERLAY_ID);
|
|
||||||
this.overlayVisible = false;
|
|
||||||
}
|
|
||||||
this.dismissOverlayTimer = null;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.cancelDismissOverlay();
|
|
||||||
|
|
||||||
const handleDismiss = (id: string) => {
|
|
||||||
this.removeBannerFromList(id);
|
|
||||||
this.updateOverlay();
|
|
||||||
};
|
|
||||||
|
|
||||||
const bannersWithDismissProps = this.activeBanners.map((banner) => {
|
|
||||||
if (banner.customComponent && React.isValidElement(banner.customComponent)) {
|
|
||||||
const props: Partial<Record<string, unknown>> = {
|
|
||||||
onDismiss: () => handleDismiss(banner.id),
|
|
||||||
dismissible: banner.dismissible,
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
...banner,
|
|
||||||
customComponent: React.cloneElement(banner.customComponent, props),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return banner;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (this.overlayVisible) {
|
|
||||||
Navigation.updateProps(FLOATING_BANNER_OVERLAY_ID, {
|
|
||||||
banners: bannersWithDismissProps,
|
|
||||||
onDismiss: handleDismiss,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
showOverlay(
|
|
||||||
Screens.FLOATING_BANNER,
|
|
||||||
{
|
|
||||||
banners: bannersWithDismissProps,
|
|
||||||
onDismiss: handleDismiss,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
overlay: {
|
|
||||||
interceptTouchOutside: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
FLOATING_BANNER_OVERLAY_ID,
|
|
||||||
);
|
|
||||||
this.overlayVisible = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
showBanner(bannerConfig: FloatingBannerConfig) {
|
|
||||||
this.removeBannerFromList(bannerConfig.id);
|
|
||||||
|
|
||||||
this.activeBanners.push(bannerConfig);
|
|
||||||
|
|
||||||
this.updateOverlay();
|
|
||||||
}
|
|
||||||
|
|
||||||
showBannerWithAutoHide(bannerConfig: FloatingBannerConfig, durationMs: number = BANNER_DEFAULT_TIME_TO_HIDE) {
|
|
||||||
this.showBanner(bannerConfig);
|
|
||||||
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
this.hideBanner(bannerConfig.id);
|
|
||||||
}, durationMs);
|
|
||||||
|
|
||||||
this.autoHideTimers.set(bannerConfig.id, timer);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hides a specific banner by ID.
|
|
||||||
*
|
|
||||||
* @param bannerId - Required banner ID to hide. This ensures explicit control and prevents
|
|
||||||
* accidental dismissal of banners from other systems.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* BannerManager.hideBanner('network-error');
|
|
||||||
*
|
|
||||||
* @remarks
|
|
||||||
* If you need to clear all banners, use {@link hideAllBanners} instead.
|
|
||||||
*/
|
|
||||||
hideBanner(bannerId: string) {
|
|
||||||
this.removeBannerFromList(bannerId);
|
|
||||||
this.updateOverlay();
|
|
||||||
}
|
|
||||||
|
|
||||||
hideAllBanners() {
|
|
||||||
this.clearAllTimers();
|
|
||||||
this.activeBanners = [];
|
|
||||||
this.updateOverlay();
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanup() {
|
|
||||||
this.clearAllTimers();
|
|
||||||
this.cancelDismissOverlay();
|
|
||||||
this.activeBanners = [];
|
|
||||||
if (this.overlayVisible) {
|
|
||||||
dismissOverlay(FLOATING_BANNER_OVERLAY_ID);
|
|
||||||
this.overlayVisible = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export const BannerManager = new BannerManagerSingleton();
|
|
||||||
|
|
||||||
export const testExports = {
|
|
||||||
BannerManager: BannerManagerSingleton,
|
|
||||||
};
|
|
||||||
|
|
@ -1,717 +0,0 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
||||||
// See LICENSE.txt for license information.
|
|
||||||
|
|
||||||
import {BannerManager} from './banner_manager';
|
|
||||||
import NetworkConnectivityManager, {testExports} from './network_connectivity_manager';
|
|
||||||
|
|
||||||
const {
|
|
||||||
shouldShowDisconnectedBanner,
|
|
||||||
shouldShowConnectingBanner,
|
|
||||||
shouldShowPerformanceBanner,
|
|
||||||
shouldShowReconnectionBanner,
|
|
||||||
} = testExports;
|
|
||||||
|
|
||||||
// Define constants locally for testing
|
|
||||||
const FLOATING_BANNER_OVERLAY_ID = 'floating-banner-overlay';
|
|
||||||
const TIME_TO_OPEN = 1000;
|
|
||||||
const TIME_TO_CLOSE = 5000;
|
|
||||||
|
|
||||||
const mockBannerManager = BannerManager as jest.Mocked<typeof BannerManager>;
|
|
||||||
|
|
||||||
jest.mock('./banner_manager', () => ({
|
|
||||||
BannerManager: {
|
|
||||||
showBanner: jest.fn(),
|
|
||||||
showBannerWithAutoHide: jest.fn(),
|
|
||||||
showBannerWithDelay: jest.fn(),
|
|
||||||
hideBanner: jest.fn(),
|
|
||||||
cleanup: jest.fn(),
|
|
||||||
getCurrentBannerId: jest.fn().mockReturnValue(null),
|
|
||||||
isBannerVisible: jest.fn().mockReturnValue(false),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
function setupConnectedState(manager: typeof NetworkConnectivityManager) {
|
|
||||||
manager.updateState('connected', {isInternetReachable: true}, 'active');
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupReconnectionScenario(manager: typeof NetworkConnectivityManager) {
|
|
||||||
setupConnectedState(manager);
|
|
||||||
manager.updateState('not_connected', {isInternetReachable: true}, 'active');
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('NetworkConnectivityManager', () => {
|
|
||||||
let manager: typeof NetworkConnectivityManager;
|
|
||||||
const mockSetTimeout = jest.spyOn(global, 'setTimeout').mockImplementation((cb: () => void) => {
|
|
||||||
cb();
|
|
||||||
return 1 as unknown as NodeJS.Timeout;
|
|
||||||
});
|
|
||||||
jest.spyOn(global, 'clearTimeout').mockImplementation(() => undefined);
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockSetTimeout.mockClear();
|
|
||||||
Object.values(mockBannerManager).forEach((mock) => {
|
|
||||||
if (typeof mock === 'function' && 'mockClear' in mock) {
|
|
||||||
mock.mockClear();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
manager = NetworkConnectivityManager;
|
|
||||||
|
|
||||||
manager.cleanup();
|
|
||||||
manager.setServerConnectionStatus(false, null);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('singleton pattern', () => {
|
|
||||||
it('should return the same instance', () => {
|
|
||||||
const instance1 = NetworkConnectivityManager;
|
|
||||||
const instance2 = NetworkConnectivityManager;
|
|
||||||
expect(instance1).toBe(instance2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('initialization', () => {
|
|
||||||
it('should initialize with server URL', () => {
|
|
||||||
const serverUrl = 'https://test.server.com';
|
|
||||||
manager.init(serverUrl);
|
|
||||||
|
|
||||||
expect((manager as any).currentServerUrl).toBe(serverUrl);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should initialize with null server URL', () => {
|
|
||||||
manager.init(null);
|
|
||||||
|
|
||||||
expect((manager as any).currentServerUrl).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reset state on initialization', () => {
|
|
||||||
manager.setServerConnectionStatus(true, 'https://old.server.com');
|
|
||||||
setupReconnectionScenario(manager);
|
|
||||||
|
|
||||||
manager.init('https://new.server.com');
|
|
||||||
|
|
||||||
expect((manager as any).currentServerUrl).toBe('https://new.server.com');
|
|
||||||
expect((manager as any).isOnAppStart).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('shutdown', () => {
|
|
||||||
it('should completely reset all state', () => {
|
|
||||||
manager.setServerConnectionStatus(true, 'https://test.server.com');
|
|
||||||
setupReconnectionScenario(manager);
|
|
||||||
|
|
||||||
manager.shutdown();
|
|
||||||
|
|
||||||
expect((manager as any).currentServerUrl).toBeNull();
|
|
||||||
expect((manager as any).websocketState).toBeNull();
|
|
||||||
expect((manager as any).netInfo).toBeNull();
|
|
||||||
expect((manager as any).appState).toBeNull();
|
|
||||||
expect((manager as any).currentPerformanceState).toBeNull();
|
|
||||||
expect((manager as any).suppressSlowPerformanceBanner).toBe(false);
|
|
||||||
expect((manager as any).isOnAppStart).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call cleanup during shutdown', () => {
|
|
||||||
manager.setServerConnectionStatus(true, 'https://test.server.com');
|
|
||||||
|
|
||||||
manager.shutdown();
|
|
||||||
|
|
||||||
expect(mockBannerManager.cleanup).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('server connection status', () => {
|
|
||||||
it('should hide banner when server is disconnected', () => {
|
|
||||||
manager.setServerConnectionStatus(false, null);
|
|
||||||
manager.updateState('not_connected', {isInternetReachable: false}, 'active');
|
|
||||||
|
|
||||||
expect(mockBannerManager.showBanner).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should show banner when server is connected after first connection', () => {
|
|
||||||
manager.setServerConnectionStatus(true, 'https://test.server.com');
|
|
||||||
|
|
||||||
setupReconnectionScenario(manager);
|
|
||||||
manager.updateState('connecting', {isInternetReachable: true}, 'active');
|
|
||||||
|
|
||||||
expect(mockBannerManager.showBanner).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should hide banner when server URL is empty', () => {
|
|
||||||
manager.setServerConnectionStatus(true, '');
|
|
||||||
manager.updateState('connecting', {isInternetReachable: true}, 'active');
|
|
||||||
|
|
||||||
expect(mockBannerManager.showBanner).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should hide banner when server URL is null', () => {
|
|
||||||
manager.setServerConnectionStatus(true, null);
|
|
||||||
manager.updateState('connecting', {isInternetReachable: true}, 'active');
|
|
||||||
|
|
||||||
expect(mockBannerManager.showBanner).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('websocket state handling', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
manager.setServerConnectionStatus(true, 'https://test.server.com');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not show banner when connecting on first connection', () => {
|
|
||||||
manager.updateState('connecting', {isInternetReachable: true}, 'active');
|
|
||||||
|
|
||||||
expect(mockBannerManager.showBanner).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not show banner when not connected on first connection', () => {
|
|
||||||
manager.setServerConnectionStatus(true, 'https://test.server.com');
|
|
||||||
|
|
||||||
manager.updateState('not_connected', {isInternetReachable: true}, 'active');
|
|
||||||
|
|
||||||
expect(mockSetTimeout).not.toHaveBeenCalled();
|
|
||||||
expect(mockBannerManager.showBanner).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should show banner when connecting after first connection', () => {
|
|
||||||
manager.setServerConnectionStatus(true, 'https://test.server.com');
|
|
||||||
|
|
||||||
setupReconnectionScenario(manager);
|
|
||||||
manager.updateState('connecting', {isInternetReachable: true}, 'active');
|
|
||||||
|
|
||||||
expect(mockBannerManager.showBanner).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should show banner with auto-hide when transitioning to connected', () => {
|
|
||||||
manager.updateState('connected', {isInternetReachable: true}, 'active');
|
|
||||||
manager.updateState('not_connected', {isInternetReachable: true}, 'active');
|
|
||||||
manager.updateState('connecting', {isInternetReachable: true}, 'active');
|
|
||||||
expect(mockBannerManager.showBanner).toHaveBeenCalled();
|
|
||||||
|
|
||||||
mockBannerManager.showBanner.mockClear();
|
|
||||||
manager.updateState('connected', {isInternetReachable: true}, 'active');
|
|
||||||
|
|
||||||
expect(mockBannerManager.showBannerWithAutoHide).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('app state handling', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
manager.setServerConnectionStatus(true, 'https://test.server.com');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should hide banner when app goes to background', () => {
|
|
||||||
manager.updateState('connected', {isInternetReachable: true}, 'active');
|
|
||||||
manager.updateState('not_connected', {isInternetReachable: true}, 'active');
|
|
||||||
manager.updateState('connecting', {isInternetReachable: true}, 'active');
|
|
||||||
expect(mockBannerManager.showBanner).toHaveBeenCalled();
|
|
||||||
|
|
||||||
mockBannerManager.hideBanner.mockClear();
|
|
||||||
manager.updateState('not_connected', {isInternetReachable: true}, 'background');
|
|
||||||
|
|
||||||
expect(mockBannerManager.hideBanner).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should show banner when app becomes active and not connected after first connection', () => {
|
|
||||||
manager.setServerConnectionStatus(true, 'https://test.server.com');
|
|
||||||
|
|
||||||
manager.updateState('connected', {isInternetReachable: true}, 'active');
|
|
||||||
manager.updateState('not_connected', {isInternetReachable: true}, 'active');
|
|
||||||
manager.updateState('not_connected', {isInternetReachable: true}, 'active');
|
|
||||||
|
|
||||||
expect(mockBannerManager.showBanner).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('cleanup', () => {
|
|
||||||
it('should hide banner and cleanup on cleanup', () => {
|
|
||||||
manager.setServerConnectionStatus(true, 'https://test.server.com');
|
|
||||||
|
|
||||||
manager.updateState('connected', {isInternetReachable: true}, 'active');
|
|
||||||
manager.updateState('not_connected', {isInternetReachable: true}, 'active');
|
|
||||||
manager.updateState('connecting', {isInternetReachable: true}, 'active');
|
|
||||||
|
|
||||||
manager.cleanup();
|
|
||||||
|
|
||||||
expect(mockBannerManager.cleanup).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('connection restoration behavior', () => {
|
|
||||||
it('should not show banner on initial connection', () => {
|
|
||||||
manager.setServerConnectionStatus(true, 'https://test.server.com');
|
|
||||||
|
|
||||||
expect((manager as any).previousWebsocketState).toBeNull();
|
|
||||||
manager.updateState('connected', {isInternetReachable: true}, 'active');
|
|
||||||
|
|
||||||
expect(mockBannerManager.showBanner).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should show banner when transitioning from disconnected to connected after first connection', () => {
|
|
||||||
manager.setServerConnectionStatus(true, 'https://test.server.com');
|
|
||||||
|
|
||||||
manager.updateState('connected', {isInternetReachable: true}, 'active');
|
|
||||||
manager.updateState('not_connected', {isInternetReachable: true}, 'active');
|
|
||||||
mockBannerManager.showBannerWithAutoHide.mockClear();
|
|
||||||
|
|
||||||
manager.updateState('connected', {isInternetReachable: true}, 'active');
|
|
||||||
|
|
||||||
expect(mockBannerManager.showBannerWithAutoHide).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not show banner when transitioning from connecting to connected on first connection', () => {
|
|
||||||
manager.setServerConnectionStatus(true, 'https://test.server.com');
|
|
||||||
|
|
||||||
manager.updateState('connecting', {isInternetReachable: true}, 'active');
|
|
||||||
mockBannerManager.showBanner.mockClear();
|
|
||||||
|
|
||||||
manager.updateState('connected', {isInternetReachable: true}, 'active');
|
|
||||||
|
|
||||||
expect(mockBannerManager.showBannerWithAutoHide).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not show banner when staying connected', () => {
|
|
||||||
manager.setServerConnectionStatus(true, 'https://test.server.com');
|
|
||||||
|
|
||||||
manager.updateState('connected', {isInternetReachable: true}, 'active');
|
|
||||||
mockBannerManager.showBanner.mockClear();
|
|
||||||
|
|
||||||
manager.updateState('connected', {isInternetReachable: true}, 'active');
|
|
||||||
|
|
||||||
expect(mockBannerManager.showBanner).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('constants', () => {
|
|
||||||
it('should have correct timeout values', () => {
|
|
||||||
expect(TIME_TO_OPEN).toBe(1000);
|
|
||||||
expect(TIME_TO_CLOSE).toBe(5000);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have correct overlay ID', () => {
|
|
||||||
expect(FLOATING_BANNER_OVERLAY_ID).toBe('floating-banner-overlay');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('null state handling', () => {
|
|
||||||
it('should return status unknown when websocketState is null', () => {
|
|
||||||
manager.setServerConnectionStatus(true, 'https://test.server.com');
|
|
||||||
(manager as any).websocketState = null;
|
|
||||||
(manager as any).netInfo = {isInternetReachable: true};
|
|
||||||
|
|
||||||
const message = (manager as any).getConnectionMessage();
|
|
||||||
|
|
||||||
expect(message).toBe('Connection status unknown');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return status unknown when netInfo is null', () => {
|
|
||||||
manager.setServerConnectionStatus(true, 'https://test.server.com');
|
|
||||||
(manager as any).websocketState = 'connected';
|
|
||||||
(manager as any).netInfo = null;
|
|
||||||
|
|
||||||
const message = (manager as any).getConnectionMessage();
|
|
||||||
|
|
||||||
expect(message).toBe('Connection status unknown');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('pure functions', () => {
|
|
||||||
const {getConnectionMessageText, isReconnection} = testExports;
|
|
||||||
|
|
||||||
describe('getConnectionMessageText', () => {
|
|
||||||
const mockFormatMessage = jest.fn((descriptor) => descriptor.defaultMessage);
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockFormatMessage.mockClear();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return connected message when websocket is connected', () => {
|
|
||||||
const result = getConnectionMessageText('connected', true, mockFormatMessage);
|
|
||||||
expect(result).toBe('Connection restored');
|
|
||||||
expect(mockFormatMessage).toHaveBeenCalledWith({
|
|
||||||
id: 'connection_banner.connected',
|
|
||||||
defaultMessage: 'Connection restored',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return connecting message when websocket is connecting', () => {
|
|
||||||
const result = getConnectionMessageText('connecting', true, mockFormatMessage);
|
|
||||||
expect(result).toBe('Connecting...');
|
|
||||||
expect(mockFormatMessage).toHaveBeenCalledWith({
|
|
||||||
id: 'connection_banner.connecting',
|
|
||||||
defaultMessage: 'Connecting...',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return not reachable message when internet is reachable but not connected', () => {
|
|
||||||
const result = getConnectionMessageText('not_connected', true, mockFormatMessage);
|
|
||||||
expect(result).toBe('The server is not reachable');
|
|
||||||
expect(mockFormatMessage).toHaveBeenCalledWith({
|
|
||||||
id: 'connection_banner.not_reachable',
|
|
||||||
defaultMessage: 'The server is not reachable',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return not connected message when internet is not reachable', () => {
|
|
||||||
const result = getConnectionMessageText('not_connected', false, mockFormatMessage);
|
|
||||||
expect(result).toBe('Unable to connect to network');
|
|
||||||
expect(mockFormatMessage).toHaveBeenCalledWith({
|
|
||||||
id: 'connection_banner.not_connected',
|
|
||||||
defaultMessage: 'Unable to connect to network',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('isReconnection', () => {
|
|
||||||
it('should return true when previous state was not_connected and not first connection', () => {
|
|
||||||
const result = isReconnection('not_connected', false);
|
|
||||||
expect(result).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return true when previous state was connecting and not first connection', () => {
|
|
||||||
const result = isReconnection('connecting', false);
|
|
||||||
expect(result).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false when previous state was connected', () => {
|
|
||||||
const result = isReconnection('connected', false);
|
|
||||||
expect(result).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false when it is first connection regardless of previous state', () => {
|
|
||||||
expect(isReconnection('not_connected', true)).toBe(false);
|
|
||||||
expect(isReconnection('connecting', true)).toBe(false);
|
|
||||||
expect(isReconnection('connected', true)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return true when previous state is null and not first connection', () => {
|
|
||||||
const result = isReconnection(null, false);
|
|
||||||
expect(result).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('shouldShowDisconnectedBanner', () => {
|
|
||||||
it('should return true when websocket is not_connected and not first connection', () => {
|
|
||||||
const result = shouldShowDisconnectedBanner('not_connected', false);
|
|
||||||
expect(result).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false when websocket is not_connected but is first connection', () => {
|
|
||||||
const result = shouldShowDisconnectedBanner('not_connected', true);
|
|
||||||
expect(result).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false when websocket is connected', () => {
|
|
||||||
const result = shouldShowDisconnectedBanner('connected', false);
|
|
||||||
expect(result).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false when websocket is connecting', () => {
|
|
||||||
const result = shouldShowDisconnectedBanner('connecting', false);
|
|
||||||
expect(result).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false when websocket state is null', () => {
|
|
||||||
const result = shouldShowDisconnectedBanner(null, false);
|
|
||||||
expect(result).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('shouldShowConnectingBanner', () => {
|
|
||||||
it('should return true when websocket is connecting and not first connection', () => {
|
|
||||||
const result = shouldShowConnectingBanner('connecting', false);
|
|
||||||
expect(result).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false when websocket is connecting but is first connection', () => {
|
|
||||||
const result = shouldShowConnectingBanner('connecting', true);
|
|
||||||
expect(result).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false when websocket is connected', () => {
|
|
||||||
const result = shouldShowConnectingBanner('connected', false);
|
|
||||||
expect(result).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false when websocket is not_connected', () => {
|
|
||||||
const result = shouldShowConnectingBanner('not_connected', false);
|
|
||||||
expect(result).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false when websocket state is null', () => {
|
|
||||||
const result = shouldShowConnectingBanner(null, false);
|
|
||||||
expect(result).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('shouldShowPerformanceBanner', () => {
|
|
||||||
it('should return true when performance is slow and not suppressed', () => {
|
|
||||||
const result = shouldShowPerformanceBanner('slow', false);
|
|
||||||
expect(result).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false when performance is normal', () => {
|
|
||||||
const result = shouldShowPerformanceBanner('normal', false);
|
|
||||||
expect(result).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false when performance is suppressed', () => {
|
|
||||||
const result = shouldShowPerformanceBanner('slow', true);
|
|
||||||
expect(result).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false when performance state is null', () => {
|
|
||||||
const result = shouldShowPerformanceBanner(null, false);
|
|
||||||
expect(result).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('shouldShowReconnectionBanner', () => {
|
|
||||||
it('should return true when all conditions are met for reconnection', () => {
|
|
||||||
const result = shouldShowReconnectionBanner('connected', 'not_connected', false);
|
|
||||||
expect(result).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return true when previous state was connecting', () => {
|
|
||||||
const result = shouldShowReconnectionBanner('connected', 'connecting', false);
|
|
||||||
expect(result).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false when websocket is not connected', () => {
|
|
||||||
const result = shouldShowReconnectionBanner('not_connected', 'not_connected', false);
|
|
||||||
expect(result).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false when websocket is connecting', () => {
|
|
||||||
const result = shouldShowReconnectionBanner('connecting', 'not_connected', false);
|
|
||||||
expect(result).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false when it is first connection', () => {
|
|
||||||
const result = shouldShowReconnectionBanner('connected', 'not_connected', true);
|
|
||||||
expect(result).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return true when reconnection conditions are met', () => {
|
|
||||||
const result = shouldShowReconnectionBanner('connected', 'not_connected', false);
|
|
||||||
expect(result).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false when previous state was connected', () => {
|
|
||||||
const result = shouldShowReconnectionBanner('connected', 'connected', false);
|
|
||||||
expect(result).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return true when previous state is null and not first connection', () => {
|
|
||||||
const result = shouldShowReconnectionBanner('connected', null, false);
|
|
||||||
expect(result).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false when websocket state is null', () => {
|
|
||||||
const result = shouldShowReconnectionBanner(null, 'not_connected', false);
|
|
||||||
expect(result).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('performance state handling', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
mockSetTimeout.mockClear();
|
|
||||||
Object.values(mockBannerManager).forEach((mock) => {
|
|
||||||
if (typeof mock === 'function' && 'mockClear' in mock) {
|
|
||||||
mock.mockClear();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
manager = NetworkConnectivityManager;
|
|
||||||
manager.setServerConnectionStatus(true, 'https://test.server.com');
|
|
||||||
|
|
||||||
jest.spyOn(global, 'setTimeout').mockImplementation((cb: () => void, delay: number) => {
|
|
||||||
if (!delay) {
|
|
||||||
cb();
|
|
||||||
}
|
|
||||||
return 1 as unknown as NodeJS.Timeout;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should show performance banner when performance is slow', () => {
|
|
||||||
setupConnectedState(manager);
|
|
||||||
manager.updatePerformanceState('slow');
|
|
||||||
|
|
||||||
expect(mockBannerManager.showBannerWithAutoHide).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should suppress performance banner until normal when banner is manually dismissed', () => {
|
|
||||||
manager.updateState('connected', {isInternetReachable: true}, 'active');
|
|
||||||
manager.updateState('connected', {isInternetReachable: true}, 'active');
|
|
||||||
|
|
||||||
mockBannerManager.showBannerWithAutoHide.mockClear();
|
|
||||||
manager.updatePerformanceState('slow');
|
|
||||||
|
|
||||||
expect(mockBannerManager.showBannerWithAutoHide).toHaveBeenCalled();
|
|
||||||
const performanceBannerConfig = mockBannerManager.showBannerWithAutoHide.mock.calls[0][0];
|
|
||||||
expect((performanceBannerConfig.customComponent as any).props.message).toBe('Limited network connection');
|
|
||||||
|
|
||||||
const onDismiss = performanceBannerConfig.onDismiss;
|
|
||||||
onDismiss!();
|
|
||||||
|
|
||||||
mockBannerManager.showBannerWithAutoHide.mockClear();
|
|
||||||
mockBannerManager.showBanner.mockClear();
|
|
||||||
|
|
||||||
manager.updatePerformanceState('slow');
|
|
||||||
expect(mockBannerManager.showBannerWithAutoHide).not.toHaveBeenCalled();
|
|
||||||
expect(mockBannerManager.showBanner).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reset suppression when reset is called', () => {
|
|
||||||
(manager as any).suppressSlowPerformanceBanner = true;
|
|
||||||
expect((manager as any).suppressSlowPerformanceBanner).toBe(true);
|
|
||||||
|
|
||||||
manager.reset();
|
|
||||||
|
|
||||||
expect((manager as any).suppressSlowPerformanceBanner).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should hide banner when performance returns to normal', () => {
|
|
||||||
setupConnectedState(manager);
|
|
||||||
|
|
||||||
manager.updatePerformanceState('slow');
|
|
||||||
expect(mockBannerManager.showBannerWithAutoHide).toHaveBeenCalled();
|
|
||||||
|
|
||||||
mockBannerManager.hideBanner.mockClear();
|
|
||||||
mockBannerManager.showBanner.mockClear();
|
|
||||||
mockBannerManager.showBannerWithAutoHide.mockClear();
|
|
||||||
|
|
||||||
manager.updatePerformanceState('normal');
|
|
||||||
expect(mockBannerManager.hideBanner).toHaveBeenCalled();
|
|
||||||
expect(mockBannerManager.showBannerWithAutoHide).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not hide banner if it is not a performance banner', () => {
|
|
||||||
|
|
||||||
manager.updateState('connected', {isInternetReachable: true}, 'active');
|
|
||||||
manager.updateState('not_connected', {isInternetReachable: true}, 'active');
|
|
||||||
|
|
||||||
mockBannerManager.hideBanner.mockClear();
|
|
||||||
|
|
||||||
manager.updatePerformanceState('normal');
|
|
||||||
expect(mockBannerManager.hideBanner).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should maintain performance state when performance becomes slow', () => {
|
|
||||||
manager.setServerConnectionStatus(true, 'https://test.server.com');
|
|
||||||
manager.updateState('connected', {isInternetReachable: true}, 'active');
|
|
||||||
|
|
||||||
manager.updatePerformanceState('slow');
|
|
||||||
expect(mockBannerManager.showBannerWithAutoHide).toHaveBeenCalled();
|
|
||||||
expect((manager as any).currentPerformanceState).toBe('slow');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should show banners on first disconnect/reconnect cycle after performance auto-hide', () => {
|
|
||||||
manager.setServerConnectionStatus(true, 'https://test.server.com');
|
|
||||||
manager.updateState('connected', {isInternetReachable: true}, 'active');
|
|
||||||
|
|
||||||
manager.updatePerformanceState('slow');
|
|
||||||
const performanceBannerCall = mockBannerManager.showBannerWithAutoHide.mock.calls[0][0];
|
|
||||||
expect((performanceBannerCall.customComponent as any).props.message).toBe('Limited network connection');
|
|
||||||
|
|
||||||
mockBannerManager.showBanner.mockClear();
|
|
||||||
mockBannerManager.showBannerWithAutoHide.mockClear();
|
|
||||||
mockBannerManager.hideBanner.mockClear();
|
|
||||||
|
|
||||||
manager.updateState('not_connected', {isInternetReachable: true}, 'active');
|
|
||||||
manager.updateState('connected', {isInternetReachable: true}, 'active');
|
|
||||||
|
|
||||||
mockBannerManager.showBanner.mockClear();
|
|
||||||
mockBannerManager.showBannerWithAutoHide.mockClear();
|
|
||||||
|
|
||||||
manager.updateState('not_connected', {isInternetReachable: true}, 'active');
|
|
||||||
|
|
||||||
const disconnectBannerCall = mockBannerManager.showBanner.mock.calls[0][0];
|
|
||||||
expect((disconnectBannerCall.customComponent as any).props.message).toBe('The server is not reachable');
|
|
||||||
|
|
||||||
manager.updateState('connected', {isInternetReachable: true}, 'active');
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('banner priority order', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
manager.setServerConnectionStatus(true, 'https://test.server.com');
|
|
||||||
setupConnectedState(manager);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should prioritize disconnected over performance', () => {
|
|
||||||
manager.updatePerformanceState('slow');
|
|
||||||
mockBannerManager.showBanner.mockClear();
|
|
||||||
mockBannerManager.showBannerWithAutoHide.mockClear();
|
|
||||||
|
|
||||||
manager.updateState('not_connected', {isInternetReachable: true}, 'active');
|
|
||||||
|
|
||||||
const disconnectBannerCall = mockBannerManager.showBanner.mock.calls[0][0];
|
|
||||||
expect((disconnectBannerCall.customComponent as any).props.message).toBe('The server is not reachable');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should prioritize disconnected over connecting', () => {
|
|
||||||
manager.updateState('connecting', {isInternetReachable: true}, 'active');
|
|
||||||
mockBannerManager.showBanner.mockClear();
|
|
||||||
|
|
||||||
manager.updateState('not_connected', {isInternetReachable: true}, 'active');
|
|
||||||
|
|
||||||
const disconnectBannerCall = mockBannerManager.showBanner.mock.calls[0][0];
|
|
||||||
expect((disconnectBannerCall.customComponent as any).props.message).toBe('The server is not reachable');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should prioritize performance over connecting', () => {
|
|
||||||
manager.updatePerformanceState('slow');
|
|
||||||
mockBannerManager.showBannerWithAutoHide.mockClear();
|
|
||||||
|
|
||||||
manager.updateState('connecting', {isInternetReachable: true}, 'active');
|
|
||||||
|
|
||||||
const performanceBannerCall = mockBannerManager.showBannerWithAutoHide.mock.calls[0][0];
|
|
||||||
expect((performanceBannerCall.customComponent as any).props.message).toBe('Limited network connection');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should prioritize connecting over reconnection', () => {
|
|
||||||
manager.updatePerformanceState('normal');
|
|
||||||
manager.updateState('not_connected', {isInternetReachable: true}, 'active');
|
|
||||||
mockBannerManager.showBanner.mockClear();
|
|
||||||
mockBannerManager.showBannerWithAutoHide.mockClear();
|
|
||||||
|
|
||||||
manager.updateState('connecting', {isInternetReachable: true}, 'active');
|
|
||||||
|
|
||||||
const connectingBannerCall = mockBannerManager.showBanner.mock.calls[0][0];
|
|
||||||
expect((connectingBannerCall.customComponent as any).props.message).toBe('Connecting...');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should show reconnection banner only when connected after disconnect', () => {
|
|
||||||
manager.updatePerformanceState('normal');
|
|
||||||
manager.updateState('not_connected', {isInternetReachable: true}, 'active');
|
|
||||||
mockBannerManager.showBannerWithAutoHide.mockClear();
|
|
||||||
|
|
||||||
manager.updateState('connected', {isInternetReachable: true}, 'active');
|
|
||||||
|
|
||||||
const reconnectionBannerCall = mockBannerManager.showBannerWithAutoHide.mock.calls[0][0];
|
|
||||||
expect((reconnectionBannerCall.customComponent as any).props.message).toBe('Connection restored');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should hide banner when connected and no performance issues', () => {
|
|
||||||
manager.updatePerformanceState('normal');
|
|
||||||
manager.updateState('not_connected', {isInternetReachable: true}, 'active');
|
|
||||||
manager.updateState('connected', {isInternetReachable: true}, 'active');
|
|
||||||
mockBannerManager.hideBanner.mockClear();
|
|
||||||
|
|
||||||
manager.updateState('connected', {isInternetReachable: true}, 'active');
|
|
||||||
|
|
||||||
expect(mockBannerManager.hideBanner).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle all conditions simultaneously and show highest priority', () => {
|
|
||||||
manager.updatePerformanceState('slow');
|
|
||||||
mockBannerManager.showBanner.mockClear();
|
|
||||||
mockBannerManager.showBannerWithAutoHide.mockClear();
|
|
||||||
|
|
||||||
manager.updateState('not_connected', {isInternetReachable: true}, 'active');
|
|
||||||
|
|
||||||
const disconnectBannerCall = mockBannerManager.showBanner.mock.calls[0][0];
|
|
||||||
expect((disconnectBannerCall.customComponent as any).props.message).toBe('The server is not reachable');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,307 +0,0 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
||||||
// See LICENSE.txt for license information.
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
import ConnectionBanner from '@components/connection_banner/connection_banner';
|
|
||||||
import {toMilliseconds} from '@utils/datetime';
|
|
||||||
import {getIntlShape} from '@utils/general';
|
|
||||||
|
|
||||||
import {BannerManager} from './banner_manager';
|
|
||||||
|
|
||||||
import type {NetworkPerformanceState} from './network_performance_manager';
|
|
||||||
import type {FloatingBannerConfig} from '@components/floating_banner/types';
|
|
||||||
|
|
||||||
const RECONNECTION_BANNER_DURATION = toMilliseconds({seconds: 3});
|
|
||||||
const PERFORMANCE_BANNER_DURATION = toMilliseconds({seconds: 10});
|
|
||||||
const NETWORK_STATUS_BANNER_ID = 'network-status';
|
|
||||||
|
|
||||||
function getConnectionMessageText(
|
|
||||||
websocketState: WebsocketConnectedState,
|
|
||||||
isInternetReachable: boolean | null,
|
|
||||||
formatMessage: (descriptor: {id: string; defaultMessage: string}) => string,
|
|
||||||
): string {
|
|
||||||
const isConnected = websocketState === 'connected';
|
|
||||||
|
|
||||||
if (isConnected) {
|
|
||||||
return formatMessage({id: 'connection_banner.connected', defaultMessage: 'Connection restored'});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (websocketState === 'connecting') {
|
|
||||||
return formatMessage({id: 'connection_banner.connecting', defaultMessage: 'Connecting...'});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isInternetReachable) {
|
|
||||||
return formatMessage({id: 'connection_banner.not_reachable', defaultMessage: 'The server is not reachable'});
|
|
||||||
}
|
|
||||||
|
|
||||||
return formatMessage({id: 'connection_banner.not_connected', defaultMessage: 'Unable to connect to network'});
|
|
||||||
}
|
|
||||||
|
|
||||||
function isReconnection(
|
|
||||||
previousWebsocketState: WebsocketConnectedState | null,
|
|
||||||
isOnAppStart: boolean,
|
|
||||||
): boolean {
|
|
||||||
return previousWebsocketState !== 'connected' && !isOnAppStart;
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldShowDisconnectedBanner(
|
|
||||||
websocketState: WebsocketConnectedState | null,
|
|
||||||
isOnAppStart: boolean,
|
|
||||||
): boolean {
|
|
||||||
return websocketState === 'not_connected' && !isOnAppStart;
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldShowConnectingBanner(
|
|
||||||
websocketState: WebsocketConnectedState | null,
|
|
||||||
isOnAppStart: boolean,
|
|
||||||
): boolean {
|
|
||||||
return websocketState === 'connecting' && !isOnAppStart;
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldShowPerformanceBanner(
|
|
||||||
performanceState: NetworkPerformanceState | null,
|
|
||||||
suppressPerformanceBanner: boolean,
|
|
||||||
): boolean {
|
|
||||||
return performanceState === 'slow' && !suppressPerformanceBanner;
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldShowReconnectionBanner(
|
|
||||||
websocketState: WebsocketConnectedState | null,
|
|
||||||
previousWebsocketState: WebsocketConnectedState | null,
|
|
||||||
isOnAppStart: boolean,
|
|
||||||
): boolean {
|
|
||||||
return websocketState === 'connected' &&
|
|
||||||
isReconnection(previousWebsocketState, isOnAppStart);
|
|
||||||
}
|
|
||||||
|
|
||||||
class NetworkConnectivityManagerSingleton {
|
|
||||||
private currentServerUrl: string | null = null;
|
|
||||||
private websocketState: WebsocketConnectedState | null = null;
|
|
||||||
private previousWebsocketState: WebsocketConnectedState | null = null;
|
|
||||||
private isOnAppStart = true;
|
|
||||||
private netInfo: {isInternetReachable: boolean | null} | null = null;
|
|
||||||
private appState: string | null = null;
|
|
||||||
private readonly intl = getIntlShape();
|
|
||||||
private currentPerformanceState: NetworkPerformanceState | null = null;
|
|
||||||
private suppressSlowPerformanceBanner = false;
|
|
||||||
|
|
||||||
private getConnectionMessage(): string {
|
|
||||||
if (!this.websocketState || !this.netInfo) {
|
|
||||||
return this.intl.formatMessage({id: 'connection_banner.status_unknown', defaultMessage: 'Connection status unknown'});
|
|
||||||
}
|
|
||||||
|
|
||||||
return getConnectionMessageText(this.websocketState, this.netInfo.isInternetReachable, this.intl.formatMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
private getPerformanceMessage(): string {
|
|
||||||
return this.intl.formatMessage({id: 'connection_banner.limited_network_connection', defaultMessage: 'Limited network connection'});
|
|
||||||
}
|
|
||||||
|
|
||||||
private showConnectivity(isConnected: boolean, durationMs?: number) {
|
|
||||||
const message = this.getConnectionMessage();
|
|
||||||
const bannerConfig = this.createConnectivityBannerConfig(message, isConnected);
|
|
||||||
|
|
||||||
if (durationMs) {
|
|
||||||
BannerManager.showBannerWithAutoHide(bannerConfig, durationMs);
|
|
||||||
} else {
|
|
||||||
BannerManager.showBanner(bannerConfig);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private showPerformance(durationMs: number) {
|
|
||||||
const message = this.getPerformanceMessage();
|
|
||||||
const bannerConfig = this.createPerformanceBannerConfig(message);
|
|
||||||
BannerManager.showBannerWithAutoHide(bannerConfig, durationMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
private createConnectivityBannerConfig(message: string, isConnected: boolean): FloatingBannerConfig {
|
|
||||||
return {
|
|
||||||
id: NETWORK_STATUS_BANNER_ID,
|
|
||||||
dismissible: true,
|
|
||||||
customComponent: React.createElement(ConnectionBanner, {
|
|
||||||
isConnected,
|
|
||||||
message,
|
|
||||||
dismissible: true,
|
|
||||||
onDismiss: () => {
|
|
||||||
// Placeholder to enable dismiss button - actual dismissal handled by BannerManager
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
position: 'bottom',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private createPerformanceBannerConfig(message: string): FloatingBannerConfig {
|
|
||||||
return {
|
|
||||||
id: NETWORK_STATUS_BANNER_ID,
|
|
||||||
dismissible: true,
|
|
||||||
onDismiss: () => {
|
|
||||||
this.suppressSlowPerformanceBanner = true;
|
|
||||||
},
|
|
||||||
customComponent: React.createElement(ConnectionBanner, {
|
|
||||||
isConnected: false,
|
|
||||||
message,
|
|
||||||
dismissible: true,
|
|
||||||
onDismiss: () => {
|
|
||||||
// Placeholder to enable dismiss button - actual dismissal handled by BannerManager
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
position: 'bottom',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
init(serverUrl: string | null = null) {
|
|
||||||
this.currentServerUrl = serverUrl;
|
|
||||||
this.cleanup();
|
|
||||||
}
|
|
||||||
|
|
||||||
setServerConnectionStatus(connected: boolean, serverUrl: string | null = null) {
|
|
||||||
this.currentServerUrl = serverUrl;
|
|
||||||
|
|
||||||
if (!connected) {
|
|
||||||
BannerManager.hideBanner(NETWORK_STATUS_BANNER_ID);
|
|
||||||
this.websocketState = null;
|
|
||||||
this.previousWebsocketState = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateState(
|
|
||||||
websocketState: WebsocketConnectedState,
|
|
||||||
netInfo: {isInternetReachable: boolean | null},
|
|
||||||
appState: string,
|
|
||||||
) {
|
|
||||||
this.previousWebsocketState = this.websocketState;
|
|
||||||
this.websocketState = websocketState;
|
|
||||||
this.netInfo = netInfo;
|
|
||||||
this.appState = appState;
|
|
||||||
|
|
||||||
this.updateBanner();
|
|
||||||
|
|
||||||
if (websocketState === 'connected' && this.isOnAppStart) {
|
|
||||||
this.isOnAppStart = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updatePerformanceState(
|
|
||||||
performanceState: NetworkPerformanceState,
|
|
||||||
) {
|
|
||||||
const wasSlowPerformance = this.currentPerformanceState === 'slow';
|
|
||||||
this.currentPerformanceState = performanceState;
|
|
||||||
|
|
||||||
// Performance state changes require special handling to avoid showing reconnection banners.
|
|
||||||
// We can't always call updateBanner() like updateState() does, because it would re-evaluate
|
|
||||||
// all banner conditions (including reconnection) when only performance has changed.
|
|
||||||
|
|
||||||
// Show slow performance banner (suppression check here prevents unnecessary updateBanner calls.
|
|
||||||
// suppressSlowPerformanceBanner also checks suppression for calls coming from updateState).
|
|
||||||
|
|
||||||
if (performanceState === 'slow' && !this.suppressSlowPerformanceBanner) {
|
|
||||||
this.updateBanner();
|
|
||||||
} else if (wasSlowPerformance && performanceState === 'normal') {
|
|
||||||
BannerManager.hideBanner(NETWORK_STATUS_BANNER_ID);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
reset() {
|
|
||||||
this.suppressSlowPerformanceBanner = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanup() {
|
|
||||||
BannerManager.cleanup();
|
|
||||||
this.previousWebsocketState = null;
|
|
||||||
this.isOnAppStart = true;
|
|
||||||
this.reset();
|
|
||||||
}
|
|
||||||
|
|
||||||
shutdown() {
|
|
||||||
this.cleanup();
|
|
||||||
this.currentServerUrl = null;
|
|
||||||
this.websocketState = null;
|
|
||||||
this.netInfo = null;
|
|
||||||
this.appState = null;
|
|
||||||
this.currentPerformanceState = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateBanner() {
|
|
||||||
if (!this.currentServerUrl || this.appState === 'background') {
|
|
||||||
BannerManager.hideBanner(NETWORK_STATUS_BANNER_ID);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Banner priority order (highest to lowest)
|
|
||||||
// 1. Disconnected - critical connection loss
|
|
||||||
if (this.handleDisconnectedState()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Performance - slow network warning
|
|
||||||
if (this.handlePerformanceState()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Connecting - attempting to reconnect
|
|
||||||
if (this.handleConnectingState()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Reconnection - successful reconnection (auto-hide)
|
|
||||||
if (this.handleReconnectionState()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
BannerManager.hideBanner(NETWORK_STATUS_BANNER_ID);
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleDisconnectedState(): boolean {
|
|
||||||
if (shouldShowDisconnectedBanner(this.websocketState, this.isOnAppStart)) {
|
|
||||||
this.showConnectivity(false);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleConnectingState(): boolean {
|
|
||||||
if (shouldShowConnectingBanner(this.websocketState, this.isOnAppStart)) {
|
|
||||||
this.showConnectivity(false);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private handlePerformanceState(): boolean {
|
|
||||||
if (shouldShowPerformanceBanner(this.currentPerformanceState, this.suppressSlowPerformanceBanner)) {
|
|
||||||
this.showPerformance(PERFORMANCE_BANNER_DURATION);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleReconnectionState(): boolean {
|
|
||||||
if (shouldShowReconnectionBanner(
|
|
||||||
this.websocketState,
|
|
||||||
this.previousWebsocketState,
|
|
||||||
this.isOnAppStart,
|
|
||||||
)) {
|
|
||||||
this.showConnectivity(true, RECONNECTION_BANNER_DURATION);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const NetworkConnectivityManager = new NetworkConnectivityManagerSingleton();
|
|
||||||
|
|
||||||
export default NetworkConnectivityManager;
|
|
||||||
|
|
||||||
export const testExports = {
|
|
||||||
NetworkConnectivityManager: NetworkConnectivityManagerSingleton,
|
|
||||||
getConnectionMessageText,
|
|
||||||
isReconnection,
|
|
||||||
shouldShowDisconnectedBanner,
|
|
||||||
shouldShowConnectingBanner,
|
|
||||||
shouldShowPerformanceBanner,
|
|
||||||
shouldShowReconnectionBanner,
|
|
||||||
RECONNECTION_BANNER_DURATION,
|
|
||||||
PERFORMANCE_BANNER_DURATION,
|
|
||||||
NETWORK_STATUS_BANNER_ID,
|
|
||||||
};
|
|
||||||
|
|
@ -1,473 +0,0 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
||||||
// See LICENSE.txt for license information.
|
|
||||||
|
|
||||||
import NetInfo from '@react-native-community/netinfo';
|
|
||||||
import {AppState} from 'react-native';
|
|
||||||
import {Subscription} from 'rxjs';
|
|
||||||
|
|
||||||
import {subscribeActiveServers} from '@database/subscription/servers';
|
|
||||||
|
|
||||||
import NetworkConnectivityManager from './network_connectivity_manager';
|
|
||||||
import {
|
|
||||||
startNetworkConnectivitySubscriptions,
|
|
||||||
stopNetworkConnectivitySubscriptions,
|
|
||||||
shutdownNetworkConnectivitySubscriptions,
|
|
||||||
} from './network_connectivity_subscription_manager';
|
|
||||||
import NetworkPerformanceManager from './network_performance_manager';
|
|
||||||
import WebsocketManager from './websocket_manager';
|
|
||||||
|
|
||||||
type MockServer = {
|
|
||||||
url: string;
|
|
||||||
lastActiveAt: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type MockNetInfoState = {
|
|
||||||
isInternetReachable: boolean | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mock dependencies
|
|
||||||
jest.mock('@react-native-community/netinfo', () => ({
|
|
||||||
addEventListener: jest.fn(),
|
|
||||||
}));
|
|
||||||
jest.mock('react-native', () => ({
|
|
||||||
AppState: {
|
|
||||||
addEventListener: jest.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
jest.mock('@database/subscription/servers', () => ({
|
|
||||||
subscribeActiveServers: jest.fn(),
|
|
||||||
}));
|
|
||||||
jest.mock('@utils/general', () => ({
|
|
||||||
getIntlShape: jest.fn(() => ({
|
|
||||||
formatMessage: jest.fn((descriptor) => descriptor.defaultMessage),
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
jest.mock('./network_connectivity_manager', () => ({
|
|
||||||
setServerConnectionStatus: jest.fn(),
|
|
||||||
updateState: jest.fn(),
|
|
||||||
updatePerformanceState: jest.fn(),
|
|
||||||
shutdown: jest.fn(),
|
|
||||||
reset: jest.fn(),
|
|
||||||
}));
|
|
||||||
jest.mock('./websocket_manager', () => ({
|
|
||||||
observeWebsocketState: jest.fn(),
|
|
||||||
}));
|
|
||||||
jest.mock('./network_performance_manager', () => ({
|
|
||||||
observePerformanceState: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockAppState = AppState as jest.Mocked<typeof AppState>;
|
|
||||||
const mockNetInfo = NetInfo as jest.Mocked<typeof NetInfo>;
|
|
||||||
const mockSubscribeActiveServers = subscribeActiveServers as jest.MockedFunction<typeof subscribeActiveServers>;
|
|
||||||
const mockNetworkConnectivityManager = NetworkConnectivityManager as jest.Mocked<typeof NetworkConnectivityManager>;
|
|
||||||
const mockNetworkPerformanceManager = NetworkPerformanceManager as jest.Mocked<typeof NetworkPerformanceManager>;
|
|
||||||
const mockWebsocketManager = WebsocketManager as jest.Mocked<typeof WebsocketManager>;
|
|
||||||
|
|
||||||
describe('NetworkConnectivitySubscriptionManager', () => {
|
|
||||||
const mockWebsocketSubscription = {
|
|
||||||
unsubscribe: jest.fn(),
|
|
||||||
} as unknown as Subscription;
|
|
||||||
|
|
||||||
const mockPerformanceSubscription = {
|
|
||||||
unsubscribe: jest.fn(),
|
|
||||||
} as unknown as Subscription;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
|
|
||||||
mockAppState.addEventListener.mockReturnValue({
|
|
||||||
remove: jest.fn(),
|
|
||||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
||||||
|
|
||||||
mockNetInfo.addEventListener.mockReturnValue(jest.fn());
|
|
||||||
|
|
||||||
mockWebsocketManager.observeWebsocketState.mockReturnValue({
|
|
||||||
subscribe: jest.fn(() => mockWebsocketSubscription),
|
|
||||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
||||||
|
|
||||||
mockNetworkPerformanceManager.observePerformanceState.mockReturnValue({
|
|
||||||
subscribe: jest.fn(() => mockPerformanceSubscription),
|
|
||||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
shutdownNetworkConnectivitySubscriptions();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('startNetworkConnectivitySubscriptions', () => {
|
|
||||||
it('should set up app state listener on first call', () => {
|
|
||||||
startNetworkConnectivitySubscriptions();
|
|
||||||
|
|
||||||
expect(mockAppState.addEventListener).toHaveBeenCalledWith('change', expect.any(Function));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not recreate app state listener on subsequent calls', () => {
|
|
||||||
startNetworkConnectivitySubscriptions();
|
|
||||||
mockAppState.addEventListener.mockClear();
|
|
||||||
|
|
||||||
startNetworkConnectivitySubscriptions();
|
|
||||||
|
|
||||||
expect(mockAppState.addEventListener).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should set up net info listener', () => {
|
|
||||||
startNetworkConnectivitySubscriptions();
|
|
||||||
|
|
||||||
expect(mockNetInfo.addEventListener).toHaveBeenCalledWith(expect.any(Function));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should set up active servers subscription', () => {
|
|
||||||
startNetworkConnectivitySubscriptions();
|
|
||||||
|
|
||||||
expect(mockSubscribeActiveServers).toHaveBeenCalledWith(expect.any(Function));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should stop subscriptions when going to background and restart when returning to active', () => {
|
|
||||||
const mockAppStateListener = {remove: jest.fn()};
|
|
||||||
const mockNetInfoUnsubscriber = jest.fn();
|
|
||||||
const mockActiveServersUnsubscriber = {unsubscribe: jest.fn()};
|
|
||||||
|
|
||||||
mockAppState.addEventListener.mockReturnValue(mockAppStateListener as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
||||||
mockNetInfo.addEventListener.mockReturnValue(mockNetInfoUnsubscriber);
|
|
||||||
mockSubscribeActiveServers.mockReturnValue(mockActiveServersUnsubscriber as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
||||||
|
|
||||||
startNetworkConnectivitySubscriptions();
|
|
||||||
|
|
||||||
const appStateCallback = mockAppState.addEventListener.mock.calls[0][1];
|
|
||||||
|
|
||||||
appStateCallback('background');
|
|
||||||
|
|
||||||
expect(mockNetInfoUnsubscriber).toHaveBeenCalled();
|
|
||||||
expect(mockActiveServersUnsubscriber.unsubscribe).toHaveBeenCalled();
|
|
||||||
expect(mockAppStateListener.remove).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
mockNetInfo.addEventListener.mockClear();
|
|
||||||
mockSubscribeActiveServers.mockClear();
|
|
||||||
|
|
||||||
appStateCallback('active');
|
|
||||||
|
|
||||||
expect(mockNetInfo.addEventListener).toHaveBeenCalled();
|
|
||||||
expect(mockSubscribeActiveServers).toHaveBeenCalled();
|
|
||||||
expect(mockAppStateListener.remove).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not restart subscriptions when app state does not change', () => {
|
|
||||||
const mockNetInfoUnsubscriber = jest.fn();
|
|
||||||
const mockActiveServersUnsubscriber = {unsubscribe: jest.fn()};
|
|
||||||
|
|
||||||
mockNetInfo.addEventListener.mockReturnValue(mockNetInfoUnsubscriber);
|
|
||||||
mockSubscribeActiveServers.mockReturnValue(mockActiveServersUnsubscriber as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
||||||
|
|
||||||
startNetworkConnectivitySubscriptions();
|
|
||||||
|
|
||||||
const appStateCallback = mockAppState.addEventListener.mock.calls[0][1];
|
|
||||||
|
|
||||||
mockNetInfo.addEventListener.mockClear();
|
|
||||||
mockSubscribeActiveServers.mockClear();
|
|
||||||
|
|
||||||
appStateCallback('active');
|
|
||||||
|
|
||||||
expect(mockNetInfo.addEventListener).not.toHaveBeenCalled();
|
|
||||||
expect(mockSubscribeActiveServers).not.toHaveBeenCalled();
|
|
||||||
expect(mockNetInfoUnsubscriber).not.toHaveBeenCalled();
|
|
||||||
expect(mockActiveServersUnsubscriber.unsubscribe).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle net info changes', () => {
|
|
||||||
startNetworkConnectivitySubscriptions();
|
|
||||||
|
|
||||||
const netInfoCallback = mockNetInfo.addEventListener.mock.calls[0][0];
|
|
||||||
const mockNetInfoState: MockNetInfoState = {isInternetReachable: true};
|
|
||||||
netInfoCallback(mockNetInfoState as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
||||||
|
|
||||||
expect(mockNetInfo.addEventListener).toHaveBeenCalledWith(expect.any(Function));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle active servers change with no servers', async () => {
|
|
||||||
startNetworkConnectivitySubscriptions();
|
|
||||||
|
|
||||||
const serversCallback = mockSubscribeActiveServers.mock.calls[0][0];
|
|
||||||
await serversCallback([] as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
||||||
|
|
||||||
expect(mockNetworkConnectivityManager.setServerConnectionStatus).toHaveBeenCalledWith(false, null);
|
|
||||||
expect(mockNetworkConnectivityManager.shutdown).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle active servers change with servers', async () => {
|
|
||||||
const mockServers: MockServer[] = [
|
|
||||||
{url: 'https://server1.com', lastActiveAt: 1000},
|
|
||||||
{url: 'https://server2.com', lastActiveAt: 2000},
|
|
||||||
];
|
|
||||||
|
|
||||||
startNetworkConnectivitySubscriptions();
|
|
||||||
|
|
||||||
const serversCallback = mockSubscribeActiveServers.mock.calls[0][0];
|
|
||||||
await serversCallback(mockServers as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
||||||
|
|
||||||
expect(mockNetworkConnectivityManager.setServerConnectionStatus).toHaveBeenCalledWith(true, 'https://server2.com');
|
|
||||||
expect(mockWebsocketManager.observeWebsocketState).toHaveBeenCalledWith('https://server2.com');
|
|
||||||
expect(mockNetworkPerformanceManager.observePerformanceState).toHaveBeenCalledWith('https://server2.com');
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('stopNetworkConnectivitySubscriptions', () => {
|
|
||||||
it('should clean up subscriptions but preserve AppState listener', async () => {
|
|
||||||
const mockAppStateListener = {remove: jest.fn()};
|
|
||||||
const mockNetInfoUnsubscriber = jest.fn();
|
|
||||||
const mockActiveServersUnsubscriber = {unsubscribe: jest.fn()};
|
|
||||||
const mockWebsocketSubscriptionForCleanup = {unsubscribe: jest.fn()};
|
|
||||||
const mockPerformanceSubscriptionForCleanup = {unsubscribe: jest.fn()};
|
|
||||||
|
|
||||||
mockAppState.addEventListener.mockReturnValue(mockAppStateListener as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
||||||
mockNetInfo.addEventListener.mockReturnValue(mockNetInfoUnsubscriber);
|
|
||||||
mockSubscribeActiveServers.mockReturnValue(mockActiveServersUnsubscriber as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
||||||
mockWebsocketManager.observeWebsocketState.mockReturnValue({
|
|
||||||
subscribe: jest.fn(() => mockWebsocketSubscriptionForCleanup),
|
|
||||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
||||||
mockNetworkPerformanceManager.observePerformanceState.mockReturnValue({
|
|
||||||
subscribe: jest.fn(() => mockPerformanceSubscriptionForCleanup),
|
|
||||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
||||||
|
|
||||||
startNetworkConnectivitySubscriptions();
|
|
||||||
|
|
||||||
// Simulate active servers to create websocket subscription
|
|
||||||
const serversCallback = mockSubscribeActiveServers.mock.calls[0][0];
|
|
||||||
await serversCallback([{url: 'https://test.com', lastActiveAt: 1000}] as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
||||||
|
|
||||||
stopNetworkConnectivitySubscriptions();
|
|
||||||
|
|
||||||
expect(mockAppStateListener.remove).not.toHaveBeenCalled();
|
|
||||||
expect(mockNetInfoUnsubscriber).toHaveBeenCalled();
|
|
||||||
expect(mockActiveServersUnsubscriber.unsubscribe).toHaveBeenCalled();
|
|
||||||
expect(mockWebsocketSubscriptionForCleanup.unsubscribe).toHaveBeenCalled();
|
|
||||||
expect(mockPerformanceSubscriptionForCleanup.unsubscribe).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should be safe to call multiple times', () => {
|
|
||||||
const mockAppStateListener = {remove: jest.fn()};
|
|
||||||
const mockNetInfoUnsubscriber = jest.fn();
|
|
||||||
const mockActiveServersUnsubscriber = {unsubscribe: jest.fn()};
|
|
||||||
|
|
||||||
mockAppState.addEventListener.mockReturnValue(mockAppStateListener as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
||||||
mockNetInfo.addEventListener.mockReturnValue(mockNetInfoUnsubscriber);
|
|
||||||
mockSubscribeActiveServers.mockReturnValue(mockActiveServersUnsubscriber as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
||||||
|
|
||||||
startNetworkConnectivitySubscriptions();
|
|
||||||
stopNetworkConnectivitySubscriptions();
|
|
||||||
|
|
||||||
// Clear mock call counts
|
|
||||||
mockAppStateListener.remove.mockClear();
|
|
||||||
mockNetInfoUnsubscriber.mockClear();
|
|
||||||
mockActiveServersUnsubscriber.unsubscribe.mockClear();
|
|
||||||
|
|
||||||
// Call stop again - should not throw and should handle gracefully
|
|
||||||
expect(() => stopNetworkConnectivitySubscriptions()).not.toThrow();
|
|
||||||
|
|
||||||
// Verify cleanup methods are not called again (they should be no-ops)
|
|
||||||
expect(mockAppStateListener.remove).not.toHaveBeenCalled();
|
|
||||||
expect(mockNetInfoUnsubscriber).not.toHaveBeenCalled();
|
|
||||||
expect(mockActiveServersUnsubscriber.unsubscribe).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('websocket subscription handling', () => {
|
|
||||||
it('should subscribe to websocket state changes', async () => {
|
|
||||||
const mockServers: MockServer[] = [{url: 'https://test.com', lastActiveAt: 1000}];
|
|
||||||
|
|
||||||
startNetworkConnectivitySubscriptions();
|
|
||||||
|
|
||||||
const serversCallback = mockSubscribeActiveServers.mock.calls[0][0];
|
|
||||||
await serversCallback(mockServers as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
||||||
|
|
||||||
expect(mockWebsocketManager.observeWebsocketState).toHaveBeenCalledWith('https://test.com');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call updateState when websocket state changes', async () => {
|
|
||||||
let websocketCallback: ((state: string) => void) | undefined;
|
|
||||||
mockWebsocketManager.observeWebsocketState.mockReturnValue({
|
|
||||||
subscribe: jest.fn((callback: (state: string) => void) => {
|
|
||||||
websocketCallback = callback;
|
|
||||||
return mockWebsocketSubscription;
|
|
||||||
}),
|
|
||||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
||||||
|
|
||||||
const mockServers: MockServer[] = [{url: 'https://test.com', lastActiveAt: 1000}];
|
|
||||||
|
|
||||||
startNetworkConnectivitySubscriptions();
|
|
||||||
|
|
||||||
const serversCallback = mockSubscribeActiveServers.mock.calls[0][0];
|
|
||||||
await serversCallback(mockServers as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
||||||
|
|
||||||
expect(websocketCallback).toBeDefined();
|
|
||||||
websocketCallback!('connected');
|
|
||||||
|
|
||||||
expect(mockNetworkConnectivityManager.updateState).toHaveBeenCalledWith(
|
|
||||||
'connected',
|
|
||||||
{isInternetReachable: null},
|
|
||||||
'active',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should unsubscribe from previous websocket subscription when servers change', async () => {
|
|
||||||
const mockServers1: MockServer[] = [{url: 'https://server1.com', lastActiveAt: 1000}];
|
|
||||||
const mockServers2: MockServer[] = [{url: 'https://server2.com', lastActiveAt: 2000}];
|
|
||||||
|
|
||||||
startNetworkConnectivitySubscriptions();
|
|
||||||
|
|
||||||
const serversCallback = mockSubscribeActiveServers.mock.calls[0][0];
|
|
||||||
|
|
||||||
await serversCallback(mockServers1 as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
||||||
expect(mockWebsocketManager.observeWebsocketState).toHaveBeenCalledWith('https://server1.com');
|
|
||||||
|
|
||||||
await serversCallback(mockServers2 as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
||||||
expect(mockWebsocketSubscription.unsubscribe).toHaveBeenCalled();
|
|
||||||
expect(mockWebsocketManager.observeWebsocketState).toHaveBeenCalledWith('https://server2.com');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('performance subscription handling', () => {
|
|
||||||
it('should subscribe to performance state changes', async () => {
|
|
||||||
const mockServers: MockServer[] = [{url: 'https://test.com', lastActiveAt: 1000}];
|
|
||||||
|
|
||||||
startNetworkConnectivitySubscriptions();
|
|
||||||
|
|
||||||
const serversCallback = mockSubscribeActiveServers.mock.calls[0][0];
|
|
||||||
await serversCallback(mockServers as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
||||||
|
|
||||||
expect(mockNetworkPerformanceManager.observePerformanceState).toHaveBeenCalledWith('https://test.com');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call updatePerformanceState when performance state changes', async () => {
|
|
||||||
let performanceCallback: ((state: string) => void) | undefined;
|
|
||||||
mockNetworkPerformanceManager.observePerformanceState.mockReturnValue({
|
|
||||||
subscribe: jest.fn((callback: (state: string) => void) => {
|
|
||||||
performanceCallback = callback;
|
|
||||||
return mockPerformanceSubscription;
|
|
||||||
}),
|
|
||||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
||||||
|
|
||||||
const mockServers: MockServer[] = [{url: 'https://test.com', lastActiveAt: 1000}];
|
|
||||||
|
|
||||||
startNetworkConnectivitySubscriptions();
|
|
||||||
|
|
||||||
const serversCallback = mockSubscribeActiveServers.mock.calls[0][0];
|
|
||||||
await serversCallback(mockServers as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
||||||
|
|
||||||
expect(performanceCallback).toBeDefined();
|
|
||||||
performanceCallback!('slow');
|
|
||||||
|
|
||||||
expect(mockNetworkConnectivityManager.updatePerformanceState).toHaveBeenCalledWith('slow');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('edge cases', () => {
|
|
||||||
it('should find most recent server when first server is more recent', async () => {
|
|
||||||
const mockServers: MockServer[] = [
|
|
||||||
{url: 'https://server1.com', lastActiveAt: 2000},
|
|
||||||
{url: 'https://server2.com', lastActiveAt: 1000},
|
|
||||||
];
|
|
||||||
|
|
||||||
startNetworkConnectivitySubscriptions();
|
|
||||||
|
|
||||||
const serversCallback = mockSubscribeActiveServers.mock.calls[0][0];
|
|
||||||
await serversCallback(mockServers as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
||||||
|
|
||||||
expect(mockNetworkConnectivityManager.setServerConnectionStatus).toHaveBeenCalledWith(true, 'https://server1.com');
|
|
||||||
expect(mockWebsocketManager.observeWebsocketState).toHaveBeenCalledWith('https://server1.com');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reinitialize when app becomes active after subscriptions were cleared', () => {
|
|
||||||
const mockAppStateListener = {remove: jest.fn()};
|
|
||||||
const mockNetInfoUnsubscriber = jest.fn();
|
|
||||||
const mockActiveServersUnsubscriber = {unsubscribe: jest.fn()};
|
|
||||||
|
|
||||||
mockAppState.addEventListener.mockReturnValue(mockAppStateListener as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
||||||
mockNetInfo.addEventListener.mockReturnValue(mockNetInfoUnsubscriber);
|
|
||||||
mockSubscribeActiveServers.mockReturnValue(mockActiveServersUnsubscriber as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
||||||
|
|
||||||
startNetworkConnectivitySubscriptions();
|
|
||||||
|
|
||||||
const appStateCallback = mockAppState.addEventListener.mock.calls[0][1];
|
|
||||||
|
|
||||||
mockNetInfo.addEventListener.mockClear();
|
|
||||||
mockSubscribeActiveServers.mockClear();
|
|
||||||
|
|
||||||
appStateCallback('background');
|
|
||||||
|
|
||||||
expect(mockNetInfoUnsubscriber).toHaveBeenCalled();
|
|
||||||
expect(mockActiveServersUnsubscriber.unsubscribe).toHaveBeenCalled();
|
|
||||||
|
|
||||||
mockNetInfo.addEventListener.mockClear();
|
|
||||||
mockSubscribeActiveServers.mockClear();
|
|
||||||
|
|
||||||
appStateCallback('active');
|
|
||||||
|
|
||||||
expect(mockNetInfo.addEventListener).toHaveBeenCalled();
|
|
||||||
expect(mockSubscribeActiveServers).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not reinitialize when transitioning to active if subscriptions exist', async () => {
|
|
||||||
startNetworkConnectivitySubscriptions();
|
|
||||||
|
|
||||||
const appStateCallback = mockAppState.addEventListener.mock.calls[0][1];
|
|
||||||
const serversCallback = mockSubscribeActiveServers.mock.calls[0][0];
|
|
||||||
|
|
||||||
await serversCallback([{url: 'https://test.com', lastActiveAt: 1000}] as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
||||||
|
|
||||||
mockNetInfo.addEventListener.mockClear();
|
|
||||||
mockSubscribeActiveServers.mockClear();
|
|
||||||
|
|
||||||
appStateCallback('active');
|
|
||||||
|
|
||||||
expect(mockNetInfo.addEventListener).not.toHaveBeenCalled();
|
|
||||||
expect(mockSubscribeActiveServers).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle netInfo with undefined isInternetReachable', () => {
|
|
||||||
startNetworkConnectivitySubscriptions();
|
|
||||||
|
|
||||||
const netInfoCallback = mockNetInfo.addEventListener.mock.calls[0][0];
|
|
||||||
const mockNetInfoState = {} as MockNetInfoState;
|
|
||||||
netInfoCallback(mockNetInfoState as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
||||||
|
|
||||||
expect(mockNetInfo.addEventListener).toHaveBeenCalledWith(expect.any(Function));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('shutdownNetworkConnectivitySubscriptions', () => {
|
|
||||||
it('should clean up all subscriptions including AppState listener', async () => {
|
|
||||||
const mockAppStateListener = {remove: jest.fn()};
|
|
||||||
const mockNetInfoUnsubscriber = jest.fn();
|
|
||||||
const mockActiveServersUnsubscriber = {unsubscribe: jest.fn()};
|
|
||||||
const mockWebsocketSubscriptionForCleanup = {unsubscribe: jest.fn()};
|
|
||||||
const mockPerformanceSubscriptionForCleanup = {unsubscribe: jest.fn()};
|
|
||||||
|
|
||||||
mockAppState.addEventListener.mockReturnValue(mockAppStateListener as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
||||||
mockNetInfo.addEventListener.mockReturnValue(mockNetInfoUnsubscriber);
|
|
||||||
mockSubscribeActiveServers.mockReturnValue(mockActiveServersUnsubscriber as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
||||||
|
|
||||||
mockWebsocketManager.observeWebsocketState.mockReturnValue({
|
|
||||||
subscribe: jest.fn(() => mockWebsocketSubscriptionForCleanup),
|
|
||||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
||||||
|
|
||||||
mockNetworkPerformanceManager.observePerformanceState.mockReturnValue({
|
|
||||||
subscribe: jest.fn(() => mockPerformanceSubscriptionForCleanup),
|
|
||||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
||||||
|
|
||||||
startNetworkConnectivitySubscriptions();
|
|
||||||
|
|
||||||
// Simulate active servers to create websocket subscription
|
|
||||||
const serversCallback = mockSubscribeActiveServers.mock.calls[0][0];
|
|
||||||
await serversCallback([{url: 'https://test.com', lastActiveAt: 1000}] as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
||||||
|
|
||||||
shutdownNetworkConnectivitySubscriptions();
|
|
||||||
|
|
||||||
expect(mockAppStateListener.remove).toHaveBeenCalled();
|
|
||||||
expect(mockNetInfoUnsubscriber).toHaveBeenCalled();
|
|
||||||
expect(mockActiveServersUnsubscriber.unsubscribe).toHaveBeenCalled();
|
|
||||||
expect(mockWebsocketSubscriptionForCleanup.unsubscribe).toHaveBeenCalled();
|
|
||||||
expect(mockPerformanceSubscriptionForCleanup.unsubscribe).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,169 +0,0 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
||||||
// See LICENSE.txt for license information.
|
|
||||||
|
|
||||||
import NetInfo, {type NetInfoSubscription} from '@react-native-community/netinfo';
|
|
||||||
import {AppState, type AppStateStatus, type NativeEventSubscription} from 'react-native';
|
|
||||||
import {Subscription} from 'rxjs';
|
|
||||||
|
|
||||||
import {subscribeActiveServers} from '@database/subscription/servers';
|
|
||||||
|
|
||||||
import NetworkConnectivityManager from './network_connectivity_manager';
|
|
||||||
import NetworkPerformanceManager from './network_performance_manager';
|
|
||||||
import WebsocketManager from './websocket_manager';
|
|
||||||
|
|
||||||
type Server = {
|
|
||||||
url: string;
|
|
||||||
lastActiveAt: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
class NetworkConnectivitySubscriptionManagerSingleton {
|
|
||||||
private appState: AppStateStatus = 'active';
|
|
||||||
private isInternetReachable: boolean | null = null;
|
|
||||||
private appSubscription: NativeEventSubscription | undefined;
|
|
||||||
private netInfoSubscription: NetInfoSubscription | undefined;
|
|
||||||
private activeServersUnsubscriber: {unsubscribe: () => void} | undefined;
|
|
||||||
private websocketSubscription: Subscription | undefined;
|
|
||||||
private performanceSubscription: Subscription | undefined;
|
|
||||||
|
|
||||||
private findMostRecentServer = (servers: Server[]): Server | undefined => {
|
|
||||||
return servers?.length ? servers.reduce((a, b) => (b.lastActiveAt > a.lastActiveAt ? b : a)) : undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
private cleanupWebsocketSubscription = (): void => {
|
|
||||||
this.websocketSubscription?.unsubscribe();
|
|
||||||
this.websocketSubscription = undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
private cleanupPerformanceSubscription = (): void => {
|
|
||||||
this.performanceSubscription?.unsubscribe();
|
|
||||||
this.performanceSubscription = undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
private handleActiveServersChange = async (servers: Server[]): Promise<void> => {
|
|
||||||
this.cleanupWebsocketSubscription();
|
|
||||||
this.cleanupPerformanceSubscription();
|
|
||||||
|
|
||||||
const activeServer = this.findMostRecentServer(servers);
|
|
||||||
const serverUrl = activeServer?.url;
|
|
||||||
|
|
||||||
if (!serverUrl) {
|
|
||||||
NetworkConnectivityManager.setServerConnectionStatus(false, null);
|
|
||||||
NetworkConnectivityManager.shutdown();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
NetworkConnectivityManager.setServerConnectionStatus(true, serverUrl);
|
|
||||||
|
|
||||||
this.websocketSubscription = WebsocketManager.observeWebsocketState(serverUrl).subscribe((websocketState) => {
|
|
||||||
NetworkConnectivityManager.updateState(
|
|
||||||
websocketState,
|
|
||||||
{isInternetReachable: this.isInternetReachable},
|
|
||||||
this.appState,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.performanceSubscription = NetworkPerformanceManager.observePerformanceState(serverUrl).subscribe((performanceState) => {
|
|
||||||
NetworkConnectivityManager.updatePerformanceState(performanceState);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
private handleAppStateChange = (newAppState: AppStateStatus): void => {
|
|
||||||
const previousAppState = this.appState;
|
|
||||||
this.appState = newAppState;
|
|
||||||
|
|
||||||
if (previousAppState === newAppState) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newAppState === 'active') {
|
|
||||||
if (!this.netInfoSubscription || !this.activeServersUnsubscriber) {
|
|
||||||
this.init();
|
|
||||||
}
|
|
||||||
} else if (previousAppState === 'active') {
|
|
||||||
NetworkConnectivityManager.reset();
|
|
||||||
this.stop();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private handleNetInfoChange = (netInfo: {isInternetReachable: boolean | null}): void => {
|
|
||||||
this.isInternetReachable = netInfo.isInternetReachable ?? null;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes all network connectivity subscriptions including app state, network info,
|
|
||||||
* and active servers. This starts the global connectivity monitoring.
|
|
||||||
*/
|
|
||||||
public init = (): void => {
|
|
||||||
if (!this.appSubscription) {
|
|
||||||
this.appSubscription = AppState.addEventListener('change', this.handleAppStateChange);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.netInfoSubscription?.();
|
|
||||||
this.netInfoSubscription = NetInfo.addEventListener(this.handleNetInfoChange);
|
|
||||||
|
|
||||||
this.activeServersUnsubscriber?.unsubscribe?.();
|
|
||||||
this.activeServersUnsubscriber = subscribeActiveServers(this.handleActiveServersChange);
|
|
||||||
};
|
|
||||||
|
|
||||||
private cleanupAllSubscriptions = (): void => {
|
|
||||||
this.websocketSubscription?.unsubscribe();
|
|
||||||
this.performanceSubscription?.unsubscribe();
|
|
||||||
this.activeServersUnsubscriber?.unsubscribe?.();
|
|
||||||
this.netInfoSubscription?.();
|
|
||||||
|
|
||||||
this.websocketSubscription = undefined;
|
|
||||||
this.performanceSubscription = undefined;
|
|
||||||
this.activeServersUnsubscriber = undefined;
|
|
||||||
this.netInfoSubscription = undefined;
|
|
||||||
this.isInternetReachable = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stops network connectivity subscriptions and cleans up resources.
|
|
||||||
* AppState listener persists to handle app lifecycle transitions.
|
|
||||||
*/
|
|
||||||
public stop = (): void => {
|
|
||||||
this.cleanupAllSubscriptions();
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Completely shuts down all subscriptions including the persistent AppState listener.
|
|
||||||
* This should only be called when the app is completely shutting down.
|
|
||||||
*/
|
|
||||||
public shutdown = (): void => {
|
|
||||||
this.websocketSubscription?.unsubscribe();
|
|
||||||
this.performanceSubscription?.unsubscribe();
|
|
||||||
this.activeServersUnsubscriber?.unsubscribe?.();
|
|
||||||
this.netInfoSubscription?.();
|
|
||||||
this.appSubscription?.remove();
|
|
||||||
|
|
||||||
this.appState = 'active';
|
|
||||||
this.isInternetReachable = null;
|
|
||||||
this.appSubscription = undefined;
|
|
||||||
this.netInfoSubscription = undefined;
|
|
||||||
this.activeServersUnsubscriber = undefined;
|
|
||||||
this.websocketSubscription = undefined;
|
|
||||||
this.performanceSubscription = undefined;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const NetworkConnectivitySubscriptionManager = new NetworkConnectivitySubscriptionManagerSingleton();
|
|
||||||
|
|
||||||
export const startNetworkConnectivitySubscriptions = (): void => {
|
|
||||||
NetworkConnectivitySubscriptionManager.init();
|
|
||||||
};
|
|
||||||
|
|
||||||
export const stopNetworkConnectivitySubscriptions = (): void => {
|
|
||||||
NetworkConnectivitySubscriptionManager.stop();
|
|
||||||
};
|
|
||||||
|
|
||||||
export const shutdownNetworkConnectivitySubscriptions = (): void => {
|
|
||||||
NetworkConnectivitySubscriptionManager.shutdown();
|
|
||||||
};
|
|
||||||
|
|
||||||
export default NetworkConnectivitySubscriptionManager;
|
|
||||||
|
|
||||||
export const testExports = {
|
|
||||||
NetworkConnectivitySubscriptionManagerSingleton,
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
@ -139,7 +139,7 @@ class NetworkManagerSingleton {
|
||||||
timeoutIntervalForRequest: managedConfig?.timeout ? parseInt(managedConfig.timeout, 10) : this.DEFAULT_CONFIG.sessionConfiguration?.timeoutIntervalForRequest,
|
timeoutIntervalForRequest: managedConfig?.timeout ? parseInt(managedConfig.timeout, 10) : this.DEFAULT_CONFIG.sessionConfiguration?.timeoutIntervalForRequest,
|
||||||
timeoutIntervalForResource: managedConfig?.timeoutVPN ? parseInt(managedConfig.timeoutVPN, 10) : this.DEFAULT_CONFIG.sessionConfiguration?.timeoutIntervalForResource,
|
timeoutIntervalForResource: managedConfig?.timeoutVPN ? parseInt(managedConfig.timeoutVPN, 10) : this.DEFAULT_CONFIG.sessionConfiguration?.timeoutIntervalForResource,
|
||||||
waitsForConnectivity: managedConfig?.useVPN === 'true',
|
waitsForConnectivity: managedConfig?.useVPN === 'true',
|
||||||
collectMetrics: true,
|
collectMetrics: LocalConfig.CollectNetworkMetrics,
|
||||||
},
|
},
|
||||||
headers,
|
headers,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,726 +0,0 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
||||||
// See LICENSE.txt for license information.
|
|
||||||
|
|
||||||
/* eslint-disable max-lines */
|
|
||||||
|
|
||||||
import {of as of$} from 'rxjs';
|
|
||||||
|
|
||||||
import {testExports} from './network_performance_manager';
|
|
||||||
|
|
||||||
import type {ClientResponseMetrics} from '@mattermost/react-native-network-client';
|
|
||||||
import type {AppStateStatus} from 'react-native';
|
|
||||||
|
|
||||||
jest.mock('@utils/log', () => ({
|
|
||||||
logDebug: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('react-native', () => ({
|
|
||||||
AppState: {
|
|
||||||
addEventListener: jest.fn(() => ({
|
|
||||||
remove: jest.fn(),
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('@queries/app/global', () => {
|
|
||||||
const {of: mockOf} = require('rxjs');
|
|
||||||
return {
|
|
||||||
observeLowConnectivityMonitor: jest.fn(() => mockOf(true)),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
|
||||||
NetworkPerformanceManagerSingleton,
|
|
||||||
REQUEST_OUTCOME_WINDOW_SIZE,
|
|
||||||
MINIMUM_REQUESTS_FOR_INITIAL_DETECTION,
|
|
||||||
MINIMUM_REQUESTS_FOR_SUBSEQUENT_DETECTION,
|
|
||||||
calculatePerformanceStateFromOutcomes,
|
|
||||||
} = testExports;
|
|
||||||
|
|
||||||
const createMockMetrics = (latency: number, size: number, compressedSize: number): ClientResponseMetrics => ({
|
|
||||||
latency,
|
|
||||||
size,
|
|
||||||
compressedSize,
|
|
||||||
startTime: Date.now(),
|
|
||||||
endTime: Date.now() + latency,
|
|
||||||
networkType: 'wifi',
|
|
||||||
tlsCipherSuite: 'TLS_AES_256_GCM_SHA384',
|
|
||||||
tlsVersion: 'TLSv1.3',
|
|
||||||
httpVersion: 'HTTP/2',
|
|
||||||
isCached: false,
|
|
||||||
connectionTime: 0,
|
|
||||||
speedInMbps: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Pure Functions', () => {
|
|
||||||
describe('calculatePerformanceStateFromOutcomes', () => {
|
|
||||||
describe('initial detection', () => {
|
|
||||||
it('should return current state when not enough requests for initial detection', () => {
|
|
||||||
const outcomes = [
|
|
||||||
{timestamp: Date.now(), isSlow: true, wasEarlyDetection: false},
|
|
||||||
{timestamp: Date.now(), isSlow: true, wasEarlyDetection: false},
|
|
||||||
];
|
|
||||||
|
|
||||||
const state = calculatePerformanceStateFromOutcomes(outcomes, true, 'normal');
|
|
||||||
expect(state).toBe('normal');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return slow when initial detection threshold is met', () => {
|
|
||||||
const outcomes = Array.from({length: MINIMUM_REQUESTS_FOR_INITIAL_DETECTION}, (_, i) => ({
|
|
||||||
timestamp: Date.now(),
|
|
||||||
isSlow: i < 3, // 3 out of 4 = 75%
|
|
||||||
wasEarlyDetection: false,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const state = calculatePerformanceStateFromOutcomes(outcomes, true, 'normal');
|
|
||||||
expect(state).toBe('slow');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return normal when initial detection threshold is met but percentage is low', () => {
|
|
||||||
const outcomes = Array.from({length: MINIMUM_REQUESTS_FOR_INITIAL_DETECTION}, (_, i) => ({
|
|
||||||
timestamp: Date.now(),
|
|
||||||
isSlow: i < 2, // 2 out of 4 = 50%
|
|
||||||
wasEarlyDetection: false,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const state = calculatePerformanceStateFromOutcomes(outcomes, true, 'normal');
|
|
||||||
expect(state).toBe('normal');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('subsequent detection', () => {
|
|
||||||
it('should return current state with fewer requests than subsequent threshold', () => {
|
|
||||||
const outcomes = Array.from({length: 6}, (_, i) => ({
|
|
||||||
timestamp: Date.now(),
|
|
||||||
isSlow: i < 5, // 5 out of 6 = 83%
|
|
||||||
wasEarlyDetection: false,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const slowState = calculatePerformanceStateFromOutcomes(outcomes, false, 'slow');
|
|
||||||
expect(slowState).toBe('slow');
|
|
||||||
|
|
||||||
const normalState = calculatePerformanceStateFromOutcomes(outcomes, false, 'normal');
|
|
||||||
expect(normalState).toBe('normal');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return normal when slow percentage is below threshold', () => {
|
|
||||||
const outcomes = Array.from({length: 10}, (_, i) => ({
|
|
||||||
timestamp: Date.now(),
|
|
||||||
isSlow: i < 3, // 3 out of 10 = 30%
|
|
||||||
wasEarlyDetection: false,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const state = calculatePerformanceStateFromOutcomes(outcomes, false, 'slow');
|
|
||||||
expect(state).toBe('normal');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return slow when slow percentage meets threshold', () => {
|
|
||||||
const outcomes = Array.from({length: 10}, (_, i) => ({
|
|
||||||
timestamp: Date.now(),
|
|
||||||
isSlow: i < 7, // 7 out of 10 = 70%
|
|
||||||
wasEarlyDetection: false,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const state = calculatePerformanceStateFromOutcomes(outcomes, false, 'normal');
|
|
||||||
expect(state).toBe('slow');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return slow when slow percentage exceeds threshold', () => {
|
|
||||||
const outcomes = Array.from({length: 10}, (_, i) => ({
|
|
||||||
timestamp: Date.now(),
|
|
||||||
isSlow: i < 8, // 8 out of 10 = 80%
|
|
||||||
wasEarlyDetection: false,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const state = calculatePerformanceStateFromOutcomes(outcomes, false, 'normal');
|
|
||||||
expect(state).toBe('slow');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('NetworkPerformanceManager', () => {
|
|
||||||
let performanceManager: InstanceType<typeof NetworkPerformanceManagerSingleton>;
|
|
||||||
const serverUrl = 'https://test-server.com';
|
|
||||||
const {observeLowConnectivityMonitor} = require('@queries/app/global');
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
observeLowConnectivityMonitor.mockReturnValue(of$(true));
|
|
||||||
performanceManager = new NetworkPerformanceManagerSingleton();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('request tracking lifecycle', () => {
|
|
||||||
it('should start and complete request tracking', () => {
|
|
||||||
performanceManager.observePerformanceState(serverUrl);
|
|
||||||
|
|
||||||
const url = '/api/v4/users/me';
|
|
||||||
const metrics = createMockMetrics(500, 1000, 500);
|
|
||||||
|
|
||||||
const requestId = performanceManager.startRequestTracking(serverUrl, url);
|
|
||||||
expect(typeof requestId).toBe('string');
|
|
||||||
expect(requestId).toContain('-');
|
|
||||||
|
|
||||||
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
|
|
||||||
|
|
||||||
const stats = performanceManager.getRequestOutcomeStats(serverUrl);
|
|
||||||
expect(stats.totalRequests).toBe(1);
|
|
||||||
expect(stats.slowRequests).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should track slow requests correctly', () => {
|
|
||||||
performanceManager.observePerformanceState(serverUrl);
|
|
||||||
|
|
||||||
const url = '/api/v4/posts';
|
|
||||||
const slowMetrics = createMockMetrics(1000, 1000, 500);
|
|
||||||
|
|
||||||
const requestId = performanceManager.startRequestTracking(serverUrl, url);
|
|
||||||
performanceManager.completeRequestTracking(serverUrl, requestId, slowMetrics);
|
|
||||||
|
|
||||||
const stats = performanceManager.getRequestOutcomeStats(serverUrl);
|
|
||||||
expect(stats.totalRequests).toBe(1);
|
|
||||||
expect(stats.slowRequests).toBe(1);
|
|
||||||
expect(stats.slowPercentage).toBe(1.0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should cancel request tracking on failure', () => {
|
|
||||||
const url = '/api/v4/teams';
|
|
||||||
|
|
||||||
const requestId = performanceManager.startRequestTracking(serverUrl, url);
|
|
||||||
performanceManager.cancelRequestTracking(serverUrl, requestId);
|
|
||||||
|
|
||||||
const stats = performanceManager.getRequestOutcomeStats(serverUrl);
|
|
||||||
expect(stats.totalRequests).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('performance state calculation', () => {
|
|
||||||
it('should return normal when slow percentage is below threshold', () => {
|
|
||||||
const states: string[] = [];
|
|
||||||
const subscription = performanceManager.observePerformanceState(serverUrl).subscribe((state: string) => {
|
|
||||||
states.push(state);
|
|
||||||
});
|
|
||||||
|
|
||||||
for (let i = 0; i < 10; i++) {
|
|
||||||
const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`);
|
|
||||||
const metrics = createMockMetrics(i < 3 ? 1000 : 500, 1000, 500);
|
|
||||||
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
|
|
||||||
}
|
|
||||||
|
|
||||||
subscription.unsubscribe();
|
|
||||||
expect(states).toContain('normal');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return slow when slow percentage meets threshold', () => {
|
|
||||||
const states: string[] = [];
|
|
||||||
const subscription = performanceManager.observePerformanceState(serverUrl).subscribe((state: string) => {
|
|
||||||
states.push(state);
|
|
||||||
});
|
|
||||||
|
|
||||||
for (let i = 0; i < 10; i++) {
|
|
||||||
const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`);
|
|
||||||
const metrics = createMockMetrics(i < 7 ? 1000 : 500, 1000, 500);
|
|
||||||
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
|
|
||||||
}
|
|
||||||
|
|
||||||
subscription.unsubscribe();
|
|
||||||
expect(states).toContain('slow');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use initial detection threshold for first slow detection', () => {
|
|
||||||
const states: string[] = [];
|
|
||||||
const subscription = performanceManager.observePerformanceState(serverUrl).subscribe((state: string) => {
|
|
||||||
states.push(state);
|
|
||||||
});
|
|
||||||
|
|
||||||
for (let i = 0; i < MINIMUM_REQUESTS_FOR_INITIAL_DETECTION; i++) {
|
|
||||||
const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`);
|
|
||||||
const metrics = createMockMetrics(i < 3 ? 1000 : 500, 1000, 500); // 3 out of 4 = 75%
|
|
||||||
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
|
|
||||||
}
|
|
||||||
|
|
||||||
subscription.unsubscribe();
|
|
||||||
expect(states).toEqual(['normal', 'slow']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should require minimum requests for subsequent detection after initial phase', () => {
|
|
||||||
const states: string[] = [];
|
|
||||||
const subscription = performanceManager.observePerformanceState(serverUrl).subscribe((state: string) => {
|
|
||||||
states.push(state);
|
|
||||||
});
|
|
||||||
|
|
||||||
for (let i = 0; i < MINIMUM_REQUESTS_FOR_INITIAL_DETECTION; i++) {
|
|
||||||
const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`);
|
|
||||||
const metrics = createMockMetrics(1000, 1000, 500);
|
|
||||||
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < MINIMUM_REQUESTS_FOR_SUBSEQUENT_DETECTION; i++) {
|
|
||||||
const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-extra-${i}`);
|
|
||||||
const metrics = createMockMetrics(i < 7 ? 1000 : 500, 1000, 500);
|
|
||||||
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
|
|
||||||
}
|
|
||||||
|
|
||||||
subscription.unsubscribe();
|
|
||||||
|
|
||||||
expect(states[0]).toBe('normal');
|
|
||||||
expect(states).toContain('slow');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('outcome statistics', () => {
|
|
||||||
it('should provide accurate request outcome statistics', () => {
|
|
||||||
performanceManager.observePerformanceState(serverUrl);
|
|
||||||
|
|
||||||
for (let i = 0; i < REQUEST_OUTCOME_WINDOW_SIZE; i++) {
|
|
||||||
const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`);
|
|
||||||
const metrics = createMockMetrics(i < 7 ? 1000 : 500, 1000, 500);
|
|
||||||
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
|
|
||||||
}
|
|
||||||
|
|
||||||
const stats = performanceManager.getRequestOutcomeStats(serverUrl);
|
|
||||||
expect(stats.totalRequests).toBe(REQUEST_OUTCOME_WINDOW_SIZE);
|
|
||||||
expect(stats.slowRequests).toBe(7);
|
|
||||||
expect(stats.slowPercentage).toBeCloseTo(7 / REQUEST_OUTCOME_WINDOW_SIZE);
|
|
||||||
expect(stats.earlyDetectionCount).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should limit outcome window size', () => {
|
|
||||||
performanceManager.observePerformanceState(serverUrl);
|
|
||||||
|
|
||||||
for (let i = 0; i < REQUEST_OUTCOME_WINDOW_SIZE + 10; i++) {
|
|
||||||
const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`);
|
|
||||||
const metrics = createMockMetrics(500, 1000, 500);
|
|
||||||
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
|
|
||||||
}
|
|
||||||
|
|
||||||
const stats = performanceManager.getRequestOutcomeStats(serverUrl);
|
|
||||||
expect(stats.totalRequests).toBe(REQUEST_OUTCOME_WINDOW_SIZE);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('server management', () => {
|
|
||||||
it('should handle multiple servers independently', () => {
|
|
||||||
const serverUrl2 = 'https://test-server-2.com';
|
|
||||||
|
|
||||||
performanceManager.observePerformanceState(serverUrl);
|
|
||||||
performanceManager.observePerformanceState(serverUrl2);
|
|
||||||
|
|
||||||
for (let i = 0; i < 10; i++) {
|
|
||||||
const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`);
|
|
||||||
const metrics = createMockMetrics(500, 1000, 500);
|
|
||||||
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < 10; i++) {
|
|
||||||
const requestId = performanceManager.startRequestTracking(serverUrl2, `/api/request-${i}`);
|
|
||||||
const metrics = createMockMetrics(i < 8 ? 1000 : 500, 1000, 500);
|
|
||||||
performanceManager.completeRequestTracking(serverUrl2, requestId, metrics);
|
|
||||||
}
|
|
||||||
|
|
||||||
const state1 = performanceManager.getCurrentPerformanceState(serverUrl);
|
|
||||||
const state2 = performanceManager.getCurrentPerformanceState(serverUrl2);
|
|
||||||
|
|
||||||
expect(state1).toBe('normal');
|
|
||||||
expect(state2).toBe('slow');
|
|
||||||
|
|
||||||
performanceManager.removeServer(serverUrl2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should clean up when server is removed', () => {
|
|
||||||
const requestId = performanceManager.startRequestTracking(serverUrl, '/api/test');
|
|
||||||
const metrics = createMockMetrics(500, 1000, 500);
|
|
||||||
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
|
|
||||||
|
|
||||||
performanceManager.removeServer(serverUrl);
|
|
||||||
|
|
||||||
const stats = performanceManager.getRequestOutcomeStats(serverUrl);
|
|
||||||
expect(stats.totalRequests).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not recreate storage when request completes after server removal', () => {
|
|
||||||
performanceManager.observePerformanceState(serverUrl);
|
|
||||||
|
|
||||||
const requestId = performanceManager.startRequestTracking(serverUrl, '/api/test');
|
|
||||||
|
|
||||||
performanceManager.removeServer(serverUrl);
|
|
||||||
|
|
||||||
const metrics = createMockMetrics(1000, 1000, 500);
|
|
||||||
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
|
|
||||||
|
|
||||||
const stats = performanceManager.getRequestOutcomeStats(serverUrl);
|
|
||||||
expect(stats.totalRequests).toBe(0);
|
|
||||||
|
|
||||||
const state = performanceManager.getCurrentPerformanceState(serverUrl);
|
|
||||||
expect(state).toBe('normal');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not recreate storage when early detection timer fires after server removal', () => {
|
|
||||||
jest.useFakeTimers();
|
|
||||||
|
|
||||||
performanceManager.observePerformanceState(serverUrl);
|
|
||||||
|
|
||||||
performanceManager.startRequestTracking(serverUrl, '/api/test');
|
|
||||||
|
|
||||||
performanceManager.removeServer(serverUrl);
|
|
||||||
|
|
||||||
jest.advanceTimersByTime(2000);
|
|
||||||
|
|
||||||
const stats = performanceManager.getRequestOutcomeStats(serverUrl);
|
|
||||||
expect(stats.totalRequests).toBe(0);
|
|
||||||
|
|
||||||
const state = performanceManager.getCurrentPerformanceState(serverUrl);
|
|
||||||
expect(state).toBe('normal');
|
|
||||||
|
|
||||||
jest.useRealTimers();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('observable behavior', () => {
|
|
||||||
it('should emit state changes when performance changes', () => {
|
|
||||||
const states: string[] = [];
|
|
||||||
const subscription = performanceManager.observePerformanceState(serverUrl).subscribe((state: string) => {
|
|
||||||
states.push(state);
|
|
||||||
});
|
|
||||||
|
|
||||||
for (let i = 0; i < MINIMUM_REQUESTS_FOR_INITIAL_DETECTION; i++) {
|
|
||||||
const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`);
|
|
||||||
const metrics = createMockMetrics(1000, 1000, 500);
|
|
||||||
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
|
|
||||||
}
|
|
||||||
|
|
||||||
subscription.unsubscribe();
|
|
||||||
expect(states).toEqual(['normal', 'slow']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('debug logging', () => {
|
|
||||||
const {logDebug} = require('@utils/log');
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
logDebug.mockClear();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should log when performance state degrades from normal to slow', () => {
|
|
||||||
performanceManager.observePerformanceState(serverUrl);
|
|
||||||
|
|
||||||
for (let i = 0; i < 10; i++) {
|
|
||||||
const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`);
|
|
||||||
const metrics = createMockMetrics(i < 8 ? 1000 : 500, 1000, 500);
|
|
||||||
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(logDebug).toHaveBeenCalledWith(
|
|
||||||
`Network performance degraded for ${serverUrl}: normal -> slow`,
|
|
||||||
expect.objectContaining({
|
|
||||||
totalRequests: expect.any(Number),
|
|
||||||
slowRequests: expect.any(Number),
|
|
||||||
slowPercentage: expect.any(String),
|
|
||||||
earlyDetectionCount: expect.any(Number),
|
|
||||||
lastOutcome: expect.objectContaining({
|
|
||||||
isSlow: expect.any(Boolean),
|
|
||||||
wasEarlyDetection: expect.any(Boolean),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not log when performance state remains the same', () => {
|
|
||||||
for (let i = 0; i < 3; i++) {
|
|
||||||
const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`);
|
|
||||||
const metrics = createMockMetrics(500, 1000, 500);
|
|
||||||
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(logDebug).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should log with correct details when performance degrades', () => {
|
|
||||||
performanceManager.observePerformanceState(serverUrl);
|
|
||||||
|
|
||||||
for (let i = 0; i < MINIMUM_REQUESTS_FOR_INITIAL_DETECTION; i++) {
|
|
||||||
const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`);
|
|
||||||
const metrics = createMockMetrics(1000, 1000, 500);
|
|
||||||
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(logDebug).toHaveBeenCalledWith(
|
|
||||||
`Network performance degraded for ${serverUrl}: normal -> slow`,
|
|
||||||
expect.objectContaining({
|
|
||||||
totalRequests: MINIMUM_REQUESTS_FOR_INITIAL_DETECTION,
|
|
||||||
slowRequests: MINIMUM_REQUESTS_FOR_INITIAL_DETECTION,
|
|
||||||
slowPercentage: '100.0%',
|
|
||||||
earlyDetectionCount: 0,
|
|
||||||
currentTimestamp: expect.any(Number),
|
|
||||||
detectionDelayMs: expect.any(Number),
|
|
||||||
detectionDelaySeconds: expect.any(String),
|
|
||||||
lastOutcome: {
|
|
||||||
isSlow: true,
|
|
||||||
wasEarlyDetection: false,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should log when performance recovers from slow to normal', () => {
|
|
||||||
performanceManager.observePerformanceState(serverUrl);
|
|
||||||
|
|
||||||
for (let i = 0; i < MINIMUM_REQUESTS_FOR_INITIAL_DETECTION; i++) {
|
|
||||||
const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`);
|
|
||||||
const metrics = createMockMetrics(1000, 1000, 500);
|
|
||||||
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
|
|
||||||
}
|
|
||||||
|
|
||||||
logDebug.mockClear();
|
|
||||||
for (let i = 0; i < 20; i++) {
|
|
||||||
const requestId = performanceManager.startRequestTracking(serverUrl, `/api/fast-${i}`);
|
|
||||||
const metrics = createMockMetrics(500, 1000, 500);
|
|
||||||
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(logDebug).toHaveBeenCalledWith(
|
|
||||||
`Network performance improved for ${serverUrl}: slow -> normal`,
|
|
||||||
expect.objectContaining({
|
|
||||||
totalRequests: expect.any(Number),
|
|
||||||
slowRequests: expect.any(Number),
|
|
||||||
slowPercentage: expect.any(String),
|
|
||||||
earlyDetectionCount: expect.any(Number),
|
|
||||||
lastOutcome: expect.objectContaining({
|
|
||||||
isSlow: expect.any(Boolean),
|
|
||||||
wasEarlyDetection: expect.any(Boolean),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('AppState monitoring', () => {
|
|
||||||
const {AppState} = require('react-native');
|
|
||||||
let appStateHandler: (nextAppState: AppStateStatus) => void;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
AppState.addEventListener.mockImplementation((event: string, handler: (nextAppState: AppStateStatus) => void) => {
|
|
||||||
if (event === 'change') {
|
|
||||||
appStateHandler = handler;
|
|
||||||
}
|
|
||||||
return {remove: jest.fn()};
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should setup AppState monitoring on construction', () => {
|
|
||||||
const manager = new NetworkPerformanceManagerSingleton();
|
|
||||||
expect(AppState.addEventListener).toHaveBeenCalledWith('change', expect.any(Function));
|
|
||||||
manager.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should clean up active requests when app goes to background', () => {
|
|
||||||
performanceManager.startRequestTracking(serverUrl, '/api/test');
|
|
||||||
|
|
||||||
expect(performanceManager.getActiveRequestCount(serverUrl)).toBe(1);
|
|
||||||
|
|
||||||
appStateHandler('background');
|
|
||||||
|
|
||||||
expect(performanceManager.getActiveRequestCount(serverUrl)).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reset performance state to normal when app becomes inactive', () => {
|
|
||||||
const states: string[] = [];
|
|
||||||
const subscription = performanceManager.observePerformanceState(serverUrl).subscribe((state: string) => {
|
|
||||||
states.push(state);
|
|
||||||
});
|
|
||||||
|
|
||||||
for (let i = 0; i < 10; i++) {
|
|
||||||
const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`);
|
|
||||||
const metrics = createMockMetrics(i < 8 ? 1000 : 500, 1000, 500);
|
|
||||||
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(performanceManager.getCurrentPerformanceState(serverUrl)).toBe('slow');
|
|
||||||
|
|
||||||
appStateHandler('inactive');
|
|
||||||
|
|
||||||
expect(performanceManager.getCurrentPerformanceState(serverUrl)).toBe('normal');
|
|
||||||
subscription.unsubscribe();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not clean up when app state is active', () => {
|
|
||||||
const requestId = performanceManager.startRequestTracking(serverUrl, '/api/test');
|
|
||||||
|
|
||||||
expect(performanceManager.getActiveRequestCount(serverUrl)).toBe(1);
|
|
||||||
|
|
||||||
appStateHandler('active');
|
|
||||||
|
|
||||||
expect(performanceManager.getActiveRequestCount(serverUrl)).toBe(1);
|
|
||||||
|
|
||||||
performanceManager.cancelRequestTracking(serverUrl, requestId);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should clean up AppState subscription on destroy', () => {
|
|
||||||
const mockRemove = jest.fn();
|
|
||||||
AppState.addEventListener.mockReturnValue({remove: mockRemove});
|
|
||||||
|
|
||||||
const manager = new NetworkPerformanceManagerSingleton();
|
|
||||||
manager.destroy();
|
|
||||||
|
|
||||||
expect(mockRemove).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Low Connectivity Monitor Integration', () => {
|
|
||||||
it('should subscribe to observeLowConnectivityMonitor on construction', () => {
|
|
||||||
expect(observeLowConnectivityMonitor).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should emit performance state changes when monitoring is enabled', () => {
|
|
||||||
const states: string[] = [];
|
|
||||||
const subscription = performanceManager.observePerformanceState(serverUrl).subscribe((state: string) => {
|
|
||||||
states.push(state);
|
|
||||||
});
|
|
||||||
|
|
||||||
for (let i = 0; i < MINIMUM_REQUESTS_FOR_INITIAL_DETECTION; i++) {
|
|
||||||
const requestId = performanceManager.startRequestTracking(serverUrl, `/api/request-${i}`);
|
|
||||||
const metrics = createMockMetrics(1000, 1000, 500);
|
|
||||||
performanceManager.completeRequestTracking(serverUrl, requestId, metrics);
|
|
||||||
}
|
|
||||||
|
|
||||||
subscription.unsubscribe();
|
|
||||||
expect(states).toEqual(['normal', 'slow']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not emit performance state changes when monitoring is disabled', () => {
|
|
||||||
observeLowConnectivityMonitor.mockReturnValue(of$(false));
|
|
||||||
const disabledManager = new NetworkPerformanceManagerSingleton();
|
|
||||||
|
|
||||||
const states: string[] = [];
|
|
||||||
const subscription = disabledManager.observePerformanceState(serverUrl).subscribe((state: string) => {
|
|
||||||
states.push(state);
|
|
||||||
});
|
|
||||||
|
|
||||||
for (let i = 0; i < 10; i++) {
|
|
||||||
const requestId = disabledManager.startRequestTracking(serverUrl, `/api/request-${i}`);
|
|
||||||
const metrics = createMockMetrics(i < 8 ? 1000 : 500, 1000, 500);
|
|
||||||
disabledManager.completeRequestTracking(serverUrl, requestId, metrics);
|
|
||||||
}
|
|
||||||
|
|
||||||
subscription.unsubscribe();
|
|
||||||
expect(states).toEqual([]);
|
|
||||||
|
|
||||||
disabledManager.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should still track requests and log when monitoring is disabled', () => {
|
|
||||||
const {logDebug} = require('@utils/log');
|
|
||||||
logDebug.mockClear();
|
|
||||||
|
|
||||||
observeLowConnectivityMonitor.mockReturnValue(of$(false));
|
|
||||||
const disabledManager = new NetworkPerformanceManagerSingleton();
|
|
||||||
|
|
||||||
disabledManager.observePerformanceState(serverUrl);
|
|
||||||
|
|
||||||
for (let i = 0; i < 10; i++) {
|
|
||||||
const requestId = disabledManager.startRequestTracking(serverUrl, `/api/request-${i}`);
|
|
||||||
const metrics = createMockMetrics(i < 8 ? 1000 : 500, 1000, 500);
|
|
||||||
disabledManager.completeRequestTracking(serverUrl, requestId, metrics);
|
|
||||||
}
|
|
||||||
|
|
||||||
const stats = disabledManager.getRequestOutcomeStats(serverUrl);
|
|
||||||
expect(stats.totalRequests).toBe(10);
|
|
||||||
expect(stats.slowRequests).toBe(8);
|
|
||||||
|
|
||||||
expect(logDebug).toHaveBeenCalledWith(
|
|
||||||
`Network performance degraded for ${serverUrl}: normal -> slow`,
|
|
||||||
expect.any(Object),
|
|
||||||
);
|
|
||||||
|
|
||||||
disabledManager.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should clean up monitor subscription on destroy', () => {
|
|
||||||
const mockUnsubscribe = jest.fn();
|
|
||||||
observeLowConnectivityMonitor.mockReturnValue({
|
|
||||||
subscribe: jest.fn(() => ({
|
|
||||||
unsubscribe: mockUnsubscribe,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
|
|
||||||
const manager = new NetworkPerformanceManagerSingleton();
|
|
||||||
manager.destroy();
|
|
||||||
|
|
||||||
expect(mockUnsubscribe).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle monitor state changing from enabled to disabled', () => {
|
|
||||||
const {BehaviorSubject} = require('rxjs');
|
|
||||||
const monitorSubject = new BehaviorSubject(true);
|
|
||||||
observeLowConnectivityMonitor.mockReturnValue(monitorSubject.asObservable());
|
|
||||||
|
|
||||||
const dynamicManager = new NetworkPerformanceManagerSingleton();
|
|
||||||
|
|
||||||
const states: string[] = [];
|
|
||||||
const subscription = dynamicManager.observePerformanceState(serverUrl).subscribe((state: string) => {
|
|
||||||
states.push(state);
|
|
||||||
});
|
|
||||||
|
|
||||||
for (let i = 0; i < MINIMUM_REQUESTS_FOR_INITIAL_DETECTION; i++) {
|
|
||||||
const requestId = dynamicManager.startRequestTracking(serverUrl, `/api/request-${i}`);
|
|
||||||
const metrics = createMockMetrics(1000, 1000, 500);
|
|
||||||
dynamicManager.completeRequestTracking(serverUrl, requestId, metrics);
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(states).toEqual(['normal', 'slow']);
|
|
||||||
|
|
||||||
monitorSubject.next(false);
|
|
||||||
|
|
||||||
states.length = 0;
|
|
||||||
|
|
||||||
for (let i = 0; i < 5; i++) {
|
|
||||||
const requestId = dynamicManager.startRequestTracking(serverUrl, `/api/request-extra-${i}`);
|
|
||||||
const metrics = createMockMetrics(500, 1000, 500);
|
|
||||||
dynamicManager.completeRequestTracking(serverUrl, requestId, metrics);
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(states).toEqual([]);
|
|
||||||
|
|
||||||
subscription.unsubscribe();
|
|
||||||
dynamicManager.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle monitor state changing from disabled to enabled', () => {
|
|
||||||
const {BehaviorSubject} = require('rxjs');
|
|
||||||
const monitorSubject = new BehaviorSubject(false);
|
|
||||||
observeLowConnectivityMonitor.mockReturnValue(monitorSubject.asObservable());
|
|
||||||
|
|
||||||
const dynamicManager = new NetworkPerformanceManagerSingleton();
|
|
||||||
|
|
||||||
const states: string[] = [];
|
|
||||||
const subscription = dynamicManager.observePerformanceState(serverUrl).subscribe((state: string) => {
|
|
||||||
states.push(state);
|
|
||||||
});
|
|
||||||
|
|
||||||
for (let i = 0; i < 5; i++) {
|
|
||||||
const requestId = dynamicManager.startRequestTracking(serverUrl, `/api/request-${i}`);
|
|
||||||
const metrics = createMockMetrics(i < 4 ? 1000 : 500, 1000, 500);
|
|
||||||
dynamicManager.completeRequestTracking(serverUrl, requestId, metrics);
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(states).toEqual([]);
|
|
||||||
|
|
||||||
monitorSubject.next(true);
|
|
||||||
|
|
||||||
for (let i = 5; i < 10; i++) {
|
|
||||||
const requestId = dynamicManager.startRequestTracking(serverUrl, `/api/request-${i}`);
|
|
||||||
const metrics = createMockMetrics(500, 1000, 500);
|
|
||||||
dynamicManager.completeRequestTracking(serverUrl, requestId, metrics);
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(states.length).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
subscription.unsubscribe();
|
|
||||||
dynamicManager.destroy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,366 +0,0 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
||||||
// See LICENSE.txt for license information.
|
|
||||||
|
|
||||||
import {AppState, type AppStateStatus, type NativeEventSubscription} from 'react-native';
|
|
||||||
import {BehaviorSubject, type Subscription} from 'rxjs';
|
|
||||||
import {distinctUntilChanged, filter} from 'rxjs/operators';
|
|
||||||
|
|
||||||
import {observeLowConnectivityMonitor} from '@queries/app/global';
|
|
||||||
import {logDebug} from '@utils/log';
|
|
||||||
|
|
||||||
import type {ClientResponseMetrics} from '@mattermost/react-native-network-client';
|
|
||||||
|
|
||||||
export type NetworkPerformanceState = 'normal' | 'slow';
|
|
||||||
|
|
||||||
interface ActiveRequest {
|
|
||||||
id: string;
|
|
||||||
url: string;
|
|
||||||
startTime: number;
|
|
||||||
checkTimer?: NodeJS.Timeout;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RequestOutcome {
|
|
||||||
timestamp: number;
|
|
||||||
isSlow: boolean;
|
|
||||||
wasEarlyDetection: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SLOW_REQUEST_THRESHOLD = 800;
|
|
||||||
const EARLY_DETECTION_SLOW_THRESHOLD = 2000;
|
|
||||||
const SLOW_REQUEST_PERCENTAGE_THRESHOLD = 0.7;
|
|
||||||
const REQUEST_OUTCOME_WINDOW_SIZE = 20;
|
|
||||||
const MINIMUM_REQUESTS_FOR_INITIAL_DETECTION = 4;
|
|
||||||
const MINIMUM_REQUESTS_FOR_SUBSEQUENT_DETECTION = 10;
|
|
||||||
|
|
||||||
const calculatePerformanceStateFromOutcomes = (outcomes: RequestOutcome[], isInitialDetection: boolean, currentState: NetworkPerformanceState): NetworkPerformanceState => {
|
|
||||||
const minimumRequests = isInitialDetection ? MINIMUM_REQUESTS_FOR_INITIAL_DETECTION : MINIMUM_REQUESTS_FOR_SUBSEQUENT_DETECTION;
|
|
||||||
|
|
||||||
if (outcomes.length < minimumRequests) {
|
|
||||||
return currentState;
|
|
||||||
}
|
|
||||||
|
|
||||||
const recentOutcomes = outcomes.slice(-minimumRequests);
|
|
||||||
const slowRequestCount = recentOutcomes.filter((outcome) => outcome.isSlow).length;
|
|
||||||
const slowPercentage = slowRequestCount / recentOutcomes.length;
|
|
||||||
|
|
||||||
return slowPercentage >= SLOW_REQUEST_PERCENTAGE_THRESHOLD ? 'slow' : 'normal';
|
|
||||||
};
|
|
||||||
|
|
||||||
class NetworkPerformanceManagerSingleton {
|
|
||||||
private performanceSubjects: Record<string, BehaviorSubject<NetworkPerformanceState>> = {};
|
|
||||||
private activeRequests: Record<string, Record<string, ActiveRequest>> = {};
|
|
||||||
private requestOutcomes: Record<string, RequestOutcome[]> = {};
|
|
||||||
private totalRequestCount: Record<string, number> = {};
|
|
||||||
private initialRequestTimestamp: Record<string, number> = {};
|
|
||||||
private isInitialDetection: Record<string, boolean> = {};
|
|
||||||
private appStateSubscription: NativeEventSubscription | null = null;
|
|
||||||
private lowConnectivityMonitorEnabled = true;
|
|
||||||
private monitorSubscription: Subscription | null = null;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.setupAppStateMonitoring();
|
|
||||||
this.setupMonitorObserver();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Starts tracking a request for early performance detection.
|
|
||||||
* Returns a unique request ID that should be used when the request completes.
|
|
||||||
*/
|
|
||||||
public startRequestTracking = (serverUrl: string, url: string): string => {
|
|
||||||
const requestId = `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
|
||||||
if (!this.activeRequests[serverUrl]) {
|
|
||||||
this.activeRequests[serverUrl] = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const request: ActiveRequest = {
|
|
||||||
id: requestId,
|
|
||||||
url,
|
|
||||||
startTime: Date.now(),
|
|
||||||
};
|
|
||||||
|
|
||||||
request.checkTimer = setTimeout(() => {
|
|
||||||
this.checkRequestLatency(serverUrl, requestId);
|
|
||||||
}, EARLY_DETECTION_SLOW_THRESHOLD);
|
|
||||||
|
|
||||||
this.activeRequests[serverUrl][requestId] = request;
|
|
||||||
return requestId;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Completes request tracking and adds metrics for performance reporting.
|
|
||||||
* Should be called when the request finishes with the ID from startRequestTracking.
|
|
||||||
*/
|
|
||||||
public completeRequestTracking = (serverUrl: string, requestId: string, metrics: ClientResponseMetrics) => {
|
|
||||||
const activeRequest = this.activeRequests[serverUrl]?.[requestId];
|
|
||||||
const wasEarlyDetected = !activeRequest; // If not found, it was already early detected and removed
|
|
||||||
|
|
||||||
this.clearActiveRequest(serverUrl, requestId);
|
|
||||||
|
|
||||||
// Only record the outcome if it wasn't already recorded by early detection
|
|
||||||
if (!wasEarlyDetected) {
|
|
||||||
this.recordRequestOutcome(serverUrl, {
|
|
||||||
timestamp: Date.now(),
|
|
||||||
isSlow: metrics.latency >= SLOW_REQUEST_THRESHOLD,
|
|
||||||
wasEarlyDetection: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cancels request tracking when a request fails.
|
|
||||||
* Should be called when the request fails with the ID from startRequestTracking.
|
|
||||||
*/
|
|
||||||
public cancelRequestTracking = (serverUrl: string, requestId: string) => {
|
|
||||||
this.clearActiveRequest(serverUrl, requestId);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns an observable that emits network performance state changes.
|
|
||||||
* Emits 'normal' or 'slow' based on current performance metrics.
|
|
||||||
* Only emits when low connectivity monitoring is enabled.
|
|
||||||
*/
|
|
||||||
public observePerformanceState = (serverUrl: string) => {
|
|
||||||
return this.getPerformanceSubject(serverUrl).asObservable().pipe(
|
|
||||||
filter(() => this.lowConnectivityMonitorEnabled),
|
|
||||||
distinctUntilChanged(),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the current network performance state for a server.
|
|
||||||
*/
|
|
||||||
public getCurrentPerformanceState = (serverUrl: string): NetworkPerformanceState => {
|
|
||||||
return this.getPerformanceSubject(serverUrl).getValue();
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the current request outcome statistics for a server.
|
|
||||||
*/
|
|
||||||
public getRequestOutcomeStats = (serverUrl: string, useRecentOnly = false) => {
|
|
||||||
let outcomes = this.requestOutcomes[serverUrl] || [];
|
|
||||||
|
|
||||||
if (useRecentOnly && outcomes.length > 0) {
|
|
||||||
const isInitialDetection = !this.isInitialDetection[serverUrl];
|
|
||||||
const minimumRequests = isInitialDetection ? MINIMUM_REQUESTS_FOR_INITIAL_DETECTION : MINIMUM_REQUESTS_FOR_SUBSEQUENT_DETECTION;
|
|
||||||
outcomes = outcomes.slice(-minimumRequests);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!outcomes.length) {
|
|
||||||
return {
|
|
||||||
totalRequests: 0,
|
|
||||||
slowRequests: 0,
|
|
||||||
slowPercentage: 0,
|
|
||||||
earlyDetectionCount: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const slowRequests = outcomes.filter((outcome) => outcome.isSlow).length;
|
|
||||||
const earlyDetectionCount = outcomes.filter((outcome) => outcome.wasEarlyDetection).length;
|
|
||||||
|
|
||||||
return {
|
|
||||||
totalRequests: outcomes.length,
|
|
||||||
slowRequests,
|
|
||||||
slowPercentage: slowRequests / outcomes.length,
|
|
||||||
earlyDetectionCount,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the count of active requests for a server (for testing purposes).
|
|
||||||
*/
|
|
||||||
public getActiveRequestCount = (serverUrl: string): number => {
|
|
||||||
return Object.keys(this.activeRequests[serverUrl] || {}).length;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Removes all data and subscriptions for a server.
|
|
||||||
*/
|
|
||||||
public removeServer = (serverUrl: string) => {
|
|
||||||
this.clearAllActiveRequests(serverUrl);
|
|
||||||
if (this.performanceSubjects[serverUrl]) {
|
|
||||||
this.performanceSubjects[serverUrl].complete();
|
|
||||||
delete this.performanceSubjects[serverUrl];
|
|
||||||
}
|
|
||||||
delete this.requestOutcomes[serverUrl];
|
|
||||||
delete this.totalRequestCount[serverUrl];
|
|
||||||
delete this.initialRequestTimestamp[serverUrl];
|
|
||||||
delete this.isInitialDetection[serverUrl];
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cleans up all resources when the manager is being destroyed.
|
|
||||||
*/
|
|
||||||
public destroy = () => {
|
|
||||||
if (this.appStateSubscription) {
|
|
||||||
this.appStateSubscription.remove();
|
|
||||||
this.appStateSubscription = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.monitorSubscription) {
|
|
||||||
this.monitorSubscription.unsubscribe();
|
|
||||||
this.monitorSubscription = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
Object.keys(this.activeRequests).forEach((serverUrl) => {
|
|
||||||
this.clearAllActiveRequests(serverUrl);
|
|
||||||
});
|
|
||||||
|
|
||||||
Object.keys(this.performanceSubjects).forEach((serverUrl) => {
|
|
||||||
this.performanceSubjects[serverUrl].complete();
|
|
||||||
});
|
|
||||||
|
|
||||||
this.performanceSubjects = {};
|
|
||||||
this.requestOutcomes = {};
|
|
||||||
this.totalRequestCount = {};
|
|
||||||
this.initialRequestTimestamp = {};
|
|
||||||
this.isInitialDetection = {};
|
|
||||||
};
|
|
||||||
|
|
||||||
private checkRequestLatency = (serverUrl: string, requestId: string) => {
|
|
||||||
const activeRequest = this.activeRequests[serverUrl]?.[requestId];
|
|
||||||
if (!activeRequest) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentTime = Date.now();
|
|
||||||
const elapsedTime = currentTime - activeRequest.startTime;
|
|
||||||
|
|
||||||
if (elapsedTime >= EARLY_DETECTION_SLOW_THRESHOLD) {
|
|
||||||
this.recordRequestOutcome(serverUrl, {
|
|
||||||
timestamp: currentTime,
|
|
||||||
isSlow: true,
|
|
||||||
wasEarlyDetection: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.clearActiveRequest(serverUrl, requestId);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private clearActiveRequest = (serverUrl: string, requestId: string) => {
|
|
||||||
const activeRequest = this.activeRequests[serverUrl]?.[requestId];
|
|
||||||
if (activeRequest?.checkTimer) {
|
|
||||||
clearTimeout(activeRequest.checkTimer);
|
|
||||||
}
|
|
||||||
if (this.activeRequests[serverUrl]) {
|
|
||||||
delete this.activeRequests[serverUrl][requestId];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private clearAllActiveRequests = (serverUrl: string) => {
|
|
||||||
const requests = this.activeRequests[serverUrl];
|
|
||||||
if (requests) {
|
|
||||||
Object.values(requests).forEach((request) => {
|
|
||||||
if (request.checkTimer) {
|
|
||||||
clearTimeout(request.checkTimer);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
delete this.activeRequests[serverUrl];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private recordRequestOutcome = (serverUrl: string, outcome: RequestOutcome) => {
|
|
||||||
if (!this.performanceSubjects[serverUrl]) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.requestOutcomes[serverUrl]) {
|
|
||||||
this.requestOutcomes[serverUrl] = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.totalRequestCount[serverUrl]) {
|
|
||||||
this.totalRequestCount[serverUrl] = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.initialRequestTimestamp[serverUrl] && outcome.isSlow) {
|
|
||||||
this.initialRequestTimestamp[serverUrl] = outcome.timestamp;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.requestOutcomes[serverUrl].push(outcome);
|
|
||||||
this.totalRequestCount[serverUrl]++;
|
|
||||||
|
|
||||||
if (this.requestOutcomes[serverUrl].length > REQUEST_OUTCOME_WINDOW_SIZE) {
|
|
||||||
this.requestOutcomes[serverUrl] = this.requestOutcomes[serverUrl].slice(-REQUEST_OUTCOME_WINDOW_SIZE);
|
|
||||||
}
|
|
||||||
|
|
||||||
const outcomes = this.requestOutcomes[serverUrl];
|
|
||||||
const currentPerformanceState = this.getCurrentPerformanceState(serverUrl);
|
|
||||||
const isInitialDetection = !this.isInitialDetection[serverUrl];
|
|
||||||
const newPerformanceState = calculatePerformanceStateFromOutcomes(outcomes, isInitialDetection, currentPerformanceState);
|
|
||||||
|
|
||||||
if (currentPerformanceState !== newPerformanceState) {
|
|
||||||
const stats = this.getRequestOutcomeStats(serverUrl, true);
|
|
||||||
const statusChange = newPerformanceState === 'slow' ? 'degraded' : 'improved';
|
|
||||||
|
|
||||||
const logData: Record<string, unknown> = {
|
|
||||||
totalRequests: stats.totalRequests,
|
|
||||||
slowRequests: stats.slowRequests,
|
|
||||||
slowPercentage: `${(stats.slowPercentage * 100).toFixed(1)}%`,
|
|
||||||
earlyDetectionCount: stats.earlyDetectionCount,
|
|
||||||
lastOutcome: {
|
|
||||||
isSlow: outcome.isSlow,
|
|
||||||
wasEarlyDetection: outcome.wasEarlyDetection,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
if (newPerformanceState === 'slow') {
|
|
||||||
const detectionDelayMs = outcome.timestamp - this.initialRequestTimestamp[serverUrl];
|
|
||||||
logData.currentTimestamp = Date.now();
|
|
||||||
logData.detectionDelayMs = detectionDelayMs;
|
|
||||||
logData.detectionDelaySeconds = `${(detectionDelayMs / 1000).toFixed(1)}s`;
|
|
||||||
|
|
||||||
this.isInitialDetection[serverUrl] = true;
|
|
||||||
delete this.initialRequestTimestamp[serverUrl];
|
|
||||||
}
|
|
||||||
|
|
||||||
logDebug(`Network performance ${statusChange} for ${serverUrl}: ${currentPerformanceState} -> ${newPerformanceState}`, logData);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.getPerformanceSubject(serverUrl).next(newPerformanceState);
|
|
||||||
};
|
|
||||||
|
|
||||||
private getPerformanceSubject = (serverUrl: string) => {
|
|
||||||
if (!this.performanceSubjects[serverUrl]) {
|
|
||||||
this.performanceSubjects[serverUrl] = new BehaviorSubject<NetworkPerformanceState>('normal');
|
|
||||||
}
|
|
||||||
return this.performanceSubjects[serverUrl];
|
|
||||||
};
|
|
||||||
|
|
||||||
private setupAppStateMonitoring = () => {
|
|
||||||
this.appStateSubscription = AppState.addEventListener('change', this.handleAppStateChange);
|
|
||||||
};
|
|
||||||
|
|
||||||
private setupMonitorObserver = () => {
|
|
||||||
this.monitorSubscription = observeLowConnectivityMonitor().subscribe((enabled) => {
|
|
||||||
this.lowConnectivityMonitorEnabled = enabled;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
private handleAppStateChange = (nextAppState: AppStateStatus) => {
|
|
||||||
if (nextAppState !== 'active') {
|
|
||||||
this.cleanupOnAppStateChange();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private cleanupOnAppStateChange = () => {
|
|
||||||
Object.keys(this.activeRequests).forEach((serverUrl) => {
|
|
||||||
this.clearAllActiveRequests(serverUrl);
|
|
||||||
});
|
|
||||||
|
|
||||||
Object.keys(this.performanceSubjects).forEach((serverUrl) => {
|
|
||||||
this.performanceSubjects[serverUrl].next('normal');
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const testExports = {
|
|
||||||
NetworkPerformanceManagerSingleton,
|
|
||||||
SLOW_REQUEST_THRESHOLD,
|
|
||||||
EARLY_DETECTION_SLOW_THRESHOLD,
|
|
||||||
REQUEST_OUTCOME_WINDOW_SIZE,
|
|
||||||
MINIMUM_REQUESTS_FOR_INITIAL_DETECTION,
|
|
||||||
MINIMUM_REQUESTS_FOR_SUBSEQUENT_DETECTION,
|
|
||||||
calculatePerformanceStateFromOutcomes,
|
|
||||||
};
|
|
||||||
|
|
||||||
const NetworkPerformanceManager = new NetworkPerformanceManagerSingleton();
|
|
||||||
export default NetworkPerformanceManager;
|
|
||||||
|
|
@ -13,10 +13,7 @@ import {resetMomentLocale} from '@i18n';
|
||||||
import {getAllServerCredentials, removeServerCredentials} from '@init/credentials';
|
import {getAllServerCredentials, removeServerCredentials} from '@init/credentials';
|
||||||
import {relaunchApp} from '@init/launch';
|
import {relaunchApp} from '@init/launch';
|
||||||
import PushNotifications from '@init/push_notifications';
|
import PushNotifications from '@init/push_notifications';
|
||||||
import NetworkConnectivityManager from '@managers/network_connectivity_manager';
|
|
||||||
import NetworkConnectivitySubscriptionManager from '@managers/network_connectivity_subscription_manager';
|
|
||||||
import NetworkManager from '@managers/network_manager';
|
import NetworkManager from '@managers/network_manager';
|
||||||
import NetworkPerformanceManager from '@managers/network_performance_manager';
|
|
||||||
import SecurityManager from '@managers/security_manager';
|
import SecurityManager from '@managers/security_manager';
|
||||||
import WebsocketManager from '@managers/websocket_manager';
|
import WebsocketManager from '@managers/websocket_manager';
|
||||||
import {getAllServers, getServerDisplayName} from '@queries/app/servers';
|
import {getAllServers, getServerDisplayName} from '@queries/app/servers';
|
||||||
|
|
@ -120,10 +117,7 @@ export class SessionManagerSingleton {
|
||||||
PushNotifications.removeServerNotifications(serverUrl);
|
PushNotifications.removeServerNotifications(serverUrl);
|
||||||
SecurityManager.removeServer(serverUrl);
|
SecurityManager.removeServer(serverUrl);
|
||||||
|
|
||||||
NetworkConnectivityManager.setServerConnectionStatus(false, serverUrl);
|
|
||||||
NetworkManager.invalidateClient(serverUrl);
|
NetworkManager.invalidateClient(serverUrl);
|
||||||
NetworkConnectivitySubscriptionManager.stop();
|
|
||||||
NetworkPerformanceManager.removeServer(serverUrl);
|
|
||||||
WebsocketManager.invalidateClient(serverUrl);
|
WebsocketManager.invalidateClient(serverUrl);
|
||||||
|
|
||||||
if (removeServer) {
|
if (removeServer) {
|
||||||
|
|
|
||||||
19
app/products/playbooks/actions/remote/playbooks.ts
Normal file
19
app/products/playbooks/actions/remote/playbooks.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import {forceLogoutIfNecessary} from '@actions/remote/session';
|
||||||
|
import NetworkManager from '@managers/network_manager';
|
||||||
|
import {getFullErrorMessage} from '@utils/errors';
|
||||||
|
import {logDebug} from '@utils/log';
|
||||||
|
|
||||||
|
export async function fetchPlaybooks(serverUrl: string, params: FetchPlaybooksParams) {
|
||||||
|
try {
|
||||||
|
const client = NetworkManager.getClient(serverUrl);
|
||||||
|
const playbooks = await client.fetchPlaybooks(params);
|
||||||
|
return {data: playbooks};
|
||||||
|
} catch (error) {
|
||||||
|
logDebug('error on fetchPlaybooks', getFullErrorMessage(error));
|
||||||
|
forceLogoutIfNecessary(serverUrl, error);
|
||||||
|
return {error};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,7 +10,7 @@ import {getLastPlaybookRunsFetchAt} from '@playbooks/database/queries/run';
|
||||||
import EphemeralStore from '@store/ephemeral_store';
|
import EphemeralStore from '@store/ephemeral_store';
|
||||||
import TestHelper from '@test/test_helper';
|
import TestHelper from '@test/test_helper';
|
||||||
|
|
||||||
import {fetchPlaybookRunsForChannel, fetchFinishedRunsForChannel, setOwner, finishRun} from './runs';
|
import {fetchPlaybookRunsForChannel, fetchFinishedRunsForChannel, fetchPlaybookRunsPageForParticipant, setOwner, finishRun} from './runs';
|
||||||
|
|
||||||
const serverUrl = 'baseHandler.test.com';
|
const serverUrl = 'baseHandler.test.com';
|
||||||
const channelId = 'channel-id-1';
|
const channelId = 'channel-id-1';
|
||||||
|
|
@ -320,3 +320,98 @@ describe('finishRun', () => {
|
||||||
expect(mockClient.finishRun).toHaveBeenCalledWith('');
|
expect(mockClient.finishRun).toHaveBeenCalledWith('');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('fetchPlaybookRunsPageForParticipant', () => {
|
||||||
|
const participantId = 'participant-id-1';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fetch single page of playbook runs for participant successfully', async () => {
|
||||||
|
const mockRuns = [mockPlaybookRun, mockPlaybookRun2];
|
||||||
|
mockClient.fetchPlaybookRuns.mockResolvedValue({
|
||||||
|
items: mockRuns,
|
||||||
|
has_more: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await fetchPlaybookRunsPageForParticipant(serverUrl, participantId);
|
||||||
|
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result.error).toBeUndefined();
|
||||||
|
expect(result.runs).toEqual(mockRuns);
|
||||||
|
expect(result.hasMore).toBe(false);
|
||||||
|
expect(mockClient.fetchPlaybookRuns).toHaveBeenCalledWith({
|
||||||
|
page: 0,
|
||||||
|
per_page: PER_PAGE_DEFAULT,
|
||||||
|
participant_id: participantId,
|
||||||
|
sort: 'create_at',
|
||||||
|
direction: 'desc',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle pagination with has_more = true', async () => {
|
||||||
|
const mockRuns = [mockPlaybookRun];
|
||||||
|
mockClient.fetchPlaybookRuns.mockResolvedValue({
|
||||||
|
items: mockRuns,
|
||||||
|
has_more: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await fetchPlaybookRunsPageForParticipant(serverUrl, participantId, 2);
|
||||||
|
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result.error).toBeUndefined();
|
||||||
|
expect(result.runs).toEqual(mockRuns);
|
||||||
|
expect(result.hasMore).toBe(true);
|
||||||
|
expect(mockClient.fetchPlaybookRuns).toHaveBeenCalledWith({
|
||||||
|
page: 2,
|
||||||
|
per_page: PER_PAGE_DEFAULT,
|
||||||
|
participant_id: participantId,
|
||||||
|
sort: 'create_at',
|
||||||
|
direction: 'desc',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle network error', async () => {
|
||||||
|
mockClient.fetchPlaybookRuns.mockRejectedValue(new Error('Network error'));
|
||||||
|
|
||||||
|
const result = await fetchPlaybookRunsPageForParticipant(serverUrl, participantId);
|
||||||
|
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result.error).toBeDefined();
|
||||||
|
expect(result.runs).toBeUndefined();
|
||||||
|
expect(result.hasMore).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty results', async () => {
|
||||||
|
mockClient.fetchPlaybookRuns.mockResolvedValue({
|
||||||
|
items: [],
|
||||||
|
has_more: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await fetchPlaybookRunsPageForParticipant(serverUrl, participantId);
|
||||||
|
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result.error).toBeUndefined();
|
||||||
|
expect(result.runs).toEqual([]);
|
||||||
|
expect(result.hasMore).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should default to page 0 when no page is provided', async () => {
|
||||||
|
const mockRuns = [mockPlaybookRun];
|
||||||
|
mockClient.fetchPlaybookRuns.mockResolvedValue({
|
||||||
|
items: mockRuns,
|
||||||
|
has_more: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await fetchPlaybookRunsPageForParticipant(serverUrl, participantId);
|
||||||
|
|
||||||
|
expect(mockClient.fetchPlaybookRuns).toHaveBeenCalledWith({
|
||||||
|
page: 0,
|
||||||
|
per_page: PER_PAGE_DEFAULT,
|
||||||
|
participant_id: participantId,
|
||||||
|
sort: 'create_at',
|
||||||
|
direction: 'desc',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -89,7 +89,7 @@ export const fetchFinishedRunsForChannel = async (serverUrl: string, channelId:
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchPlaybookRun = async (serverUrl: string, runId: string, fetchOnly = false): Promise<PlaybookRunsRequest> => {
|
export const fetchPlaybookRun = async (serverUrl: string, runId: string, fetchOnly = false) => {
|
||||||
try {
|
try {
|
||||||
const client = NetworkManager.getClient(serverUrl);
|
const client = NetworkManager.getClient(serverUrl);
|
||||||
const run = await client.fetchPlaybookRun(runId);
|
const run = await client.fetchPlaybookRun(runId);
|
||||||
|
|
@ -102,7 +102,7 @@ export const fetchPlaybookRun = async (serverUrl: string, runId: string, fetchOn
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {runs: [run]};
|
return {run};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logDebug('error on fetchPlaybookRun', getFullErrorMessage(error));
|
logDebug('error on fetchPlaybookRun', getFullErrorMessage(error));
|
||||||
forceLogoutIfNecessary(serverUrl, error);
|
forceLogoutIfNecessary(serverUrl, error);
|
||||||
|
|
@ -110,6 +110,18 @@ export const fetchPlaybookRun = async (serverUrl: string, runId: string, fetchOn
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const fetchPlaybookRunMetadata = async (serverUrl: string, runId: string) => {
|
||||||
|
try {
|
||||||
|
const client = NetworkManager.getClient(serverUrl);
|
||||||
|
const metadata = await client.fetchPlaybookRunMetadata(runId);
|
||||||
|
return {metadata};
|
||||||
|
} catch (error) {
|
||||||
|
logDebug('error on fetchPlaybookRunMetadata', getFullErrorMessage(error));
|
||||||
|
forceLogoutIfNecessary(serverUrl, error);
|
||||||
|
return {error};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const setOwner = async (serverUrl: string, playbookRunId: string, ownerId: string) => {
|
export const setOwner = async (serverUrl: string, playbookRunId: string, ownerId: string) => {
|
||||||
try {
|
try {
|
||||||
const client = NetworkManager.getClient(serverUrl);
|
const client = NetworkManager.getClient(serverUrl);
|
||||||
|
|
@ -124,6 +136,37 @@ export const setOwner = async (serverUrl: string, playbookRunId: string, ownerId
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const createPlaybookRun = async (
|
||||||
|
serverUrl: string,
|
||||||
|
playbook_id: string,
|
||||||
|
owner_user_id: string,
|
||||||
|
team_id: string,
|
||||||
|
name: string,
|
||||||
|
description: string,
|
||||||
|
channel_id?: string,
|
||||||
|
create_public_run?: boolean,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const client = NetworkManager.getClient(serverUrl);
|
||||||
|
const run = await client.createPlaybookRun(playbook_id, owner_user_id, team_id, name, description, channel_id, create_public_run);
|
||||||
|
return {data: run};
|
||||||
|
} catch (error) {
|
||||||
|
logDebug('error on createPlaybookRun', getFullErrorMessage(error));
|
||||||
|
forceLogoutIfNecessary(serverUrl, error);
|
||||||
|
return {error};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const postStatusUpdate = async (serverUrl: string, playbookRunID: string, payload: PostStatusUpdatePayload, ids: PostStatusUpdateIds) => {
|
||||||
|
try {
|
||||||
|
const client = NetworkManager.getClient(serverUrl);
|
||||||
|
await client.postStatusUpdate(playbookRunID, payload, ids);
|
||||||
|
} catch (error) {
|
||||||
|
logDebug('error on postStatusUpdate', getFullErrorMessage(error));
|
||||||
|
forceLogoutIfNecessary(serverUrl, error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const finishRun = async (serverUrl: string, playbookRunId: string) => {
|
export const finishRun = async (serverUrl: string, playbookRunId: string) => {
|
||||||
try {
|
try {
|
||||||
const client = NetworkManager.getClient(serverUrl);
|
const client = NetworkManager.getClient(serverUrl);
|
||||||
|
|
@ -135,3 +178,23 @@ export const finishRun = async (serverUrl: string, playbookRunId: string) => {
|
||||||
return {error};
|
return {error};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const fetchPlaybookRunsPageForParticipant = async (serverUrl: string, participantId: string, page = 0) => {
|
||||||
|
try {
|
||||||
|
const client = NetworkManager.getClient(serverUrl);
|
||||||
|
|
||||||
|
const {items: runs, has_more} = await client.fetchPlaybookRuns({
|
||||||
|
page,
|
||||||
|
per_page: PER_PAGE_DEFAULT,
|
||||||
|
participant_id: participantId,
|
||||||
|
sort: 'create_at',
|
||||||
|
direction: 'desc',
|
||||||
|
});
|
||||||
|
|
||||||
|
return {runs, hasMore: has_more};
|
||||||
|
} catch (error) {
|
||||||
|
logDebug('error on fetchPlaybookRunsPageForParticipant', getFullErrorMessage(error));
|
||||||
|
forceLogoutIfNecessary(serverUrl, error);
|
||||||
|
return {error};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -56,13 +56,10 @@ describe('fetchPlaybookRuns', () => {
|
||||||
|
|
||||||
test('should return default response when doFetch throws error', async () => {
|
test('should return default response when doFetch throws error', async () => {
|
||||||
const params = {team_id: 'team1', page: 0, per_page: 10};
|
const params = {team_id: 'team1', page: 0, per_page: 10};
|
||||||
const expectedResponse = {items: [], total_count: 0, page_count: 0, has_more: false};
|
|
||||||
|
|
||||||
jest.mocked(client.doFetch).mockRejectedValue(new Error('Network error'));
|
jest.mocked(client.doFetch).mockRejectedValue(new Error('Network error'));
|
||||||
|
|
||||||
const result = await client.fetchPlaybookRuns(params);
|
expect(client.fetchPlaybookRuns(params)).rejects.toThrow('Network error');
|
||||||
|
|
||||||
expect(result).toEqual(expectedResponse);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,19 @@ import type ClientBase from '@client/rest/base';
|
||||||
|
|
||||||
export interface ClientPlaybooksMix {
|
export interface ClientPlaybooksMix {
|
||||||
|
|
||||||
|
// Playbooks
|
||||||
|
fetchPlaybooks: (params: FetchPlaybooksParams) => Promise<FetchPlaybooksReturn>;
|
||||||
|
|
||||||
// Playbook Runs
|
// Playbook Runs
|
||||||
fetchPlaybookRuns: (params: FetchPlaybookRunsParams, groupLabel?: RequestGroupLabel) => Promise<FetchPlaybookRunsReturn>;
|
fetchPlaybookRuns: (params: FetchPlaybookRunsParams, groupLabel?: RequestGroupLabel) => Promise<FetchPlaybookRunsReturn>;
|
||||||
fetchPlaybookRun: (id: string, groupLabel?: RequestGroupLabel) => Promise<PlaybookRun>;
|
fetchPlaybookRun: (id: string, groupLabel?: RequestGroupLabel) => Promise<PlaybookRun>;
|
||||||
|
fetchPlaybookRunMetadata: (id: string) => Promise<PlaybookRunMetadata>;
|
||||||
setOwner: (playbookRunId: string, ownerId: string) => Promise<void>;
|
setOwner: (playbookRunId: string, ownerId: string) => Promise<void>;
|
||||||
|
|
||||||
// Run Management
|
// Run Management
|
||||||
finishRun: (playbookRunId: string) => Promise<void>;
|
finishRun: (playbookRunId: string) => Promise<void>;
|
||||||
|
createPlaybookRun: (playbook_id: string, owner_user_id: string, team_id: string, name: string, description: string, channel_id?: string, create_public_run?: boolean) => Promise<PlaybookRun>;
|
||||||
|
postStatusUpdate: (playbookRunID: string, payload: PostStatusUpdatePayload, ids: PostStatusUpdateIds) => Promise<void>;
|
||||||
|
|
||||||
// Checklist Management
|
// Checklist Management
|
||||||
setChecklistItemState: (playbookRunID: string, checklistNum: number, itemNum: number, newState: ChecklistItemState) => Promise<void>;
|
setChecklistItemState: (playbookRunID: string, checklistNum: number, itemNum: number, newState: ChecklistItemState) => Promise<void>;
|
||||||
|
|
@ -43,19 +49,26 @@ const ClientPlaybooks = <TBase extends Constructor<ClientBase>>(superclass: TBas
|
||||||
return `${this.getPlaybookRunsRoute()}/${runId}`;
|
return `${this.getPlaybookRunsRoute()}/${runId}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Playbooks
|
||||||
|
fetchPlaybooks(params: FetchPlaybooksParams) {
|
||||||
|
const queryParams = buildQueryString({
|
||||||
|
...params,
|
||||||
|
});
|
||||||
|
return this.doFetch(
|
||||||
|
`${this.getPlaybooksRoute()}/playbooks${queryParams}`,
|
||||||
|
{method: 'get'},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Playbook Runs
|
// Playbook Runs
|
||||||
fetchPlaybookRuns = async (params: FetchPlaybookRunsParams, groupLabel?: RequestGroupLabel) => {
|
fetchPlaybookRuns = async (params: FetchPlaybookRunsParams, groupLabel?: RequestGroupLabel) => {
|
||||||
const queryParams = buildQueryString(params);
|
const queryParams = buildQueryString(params);
|
||||||
|
|
||||||
try {
|
const data = await this.doFetch(
|
||||||
const data = await this.doFetch(
|
`${this.getPlaybookRunsRoute()}${queryParams}`,
|
||||||
`${this.getPlaybookRunsRoute()}${queryParams}`,
|
{method: 'get', groupLabel},
|
||||||
{method: 'get', groupLabel},
|
);
|
||||||
);
|
return data || {items: [], total_count: 0, page_count: 0, has_more: false};
|
||||||
return data || {items: [], total_count: 0, page_count: 0, has_more: false};
|
|
||||||
} catch (error) {
|
|
||||||
return {items: [], total_count: 0, page_count: 0, has_more: false};
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchPlaybookRun = async (id: string, groupLabel?: RequestGroupLabel) => {
|
fetchPlaybookRun = async (id: string, groupLabel?: RequestGroupLabel) => {
|
||||||
|
|
@ -65,6 +78,13 @@ const ClientPlaybooks = <TBase extends Constructor<ClientBase>>(superclass: TBas
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
fetchPlaybookRunMetadata = async (id: string) => {
|
||||||
|
return this.doFetch(
|
||||||
|
`${this.getPlaybookRunRoute(id)}/metadata`,
|
||||||
|
{method: 'get'},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
setOwner = async (playbookRunId: string, ownerId: string) => {
|
setOwner = async (playbookRunId: string, ownerId: string) => {
|
||||||
const data = await this.doFetch(
|
const data = await this.doFetch(
|
||||||
`${this.getPlaybookRunRoute(playbookRunId)}/owner`,
|
`${this.getPlaybookRunRoute(playbookRunId)}/owner`,
|
||||||
|
|
@ -81,6 +101,50 @@ const ClientPlaybooks = <TBase extends Constructor<ClientBase>>(superclass: TBas
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
createPlaybookRun = async (
|
||||||
|
playbook_id: string,
|
||||||
|
owner_user_id: string,
|
||||||
|
team_id: string,
|
||||||
|
name: string,
|
||||||
|
description: string,
|
||||||
|
channel_id?: string,
|
||||||
|
create_public_run?: boolean,
|
||||||
|
) => {
|
||||||
|
const data = await this.doFetch(`${this.getPlaybookRunsRoute()}`, {
|
||||||
|
method: 'post',
|
||||||
|
body: {
|
||||||
|
owner_user_id,
|
||||||
|
team_id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
playbook_id,
|
||||||
|
channel_id,
|
||||||
|
create_public_run,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
postStatusUpdate = async (playbookRunID: string, payload: PostStatusUpdatePayload, ids: PostStatusUpdateIds) => {
|
||||||
|
const body = {
|
||||||
|
type: 'dialog_submission',
|
||||||
|
callback_id: '',
|
||||||
|
state: '',
|
||||||
|
cancelled: false,
|
||||||
|
...ids,
|
||||||
|
submission: {
|
||||||
|
message: payload.message,
|
||||||
|
reminder: payload.reminder?.toFixed() ?? '',
|
||||||
|
finish_run: payload.finishRun,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.doFetch(
|
||||||
|
`${this.getPlaybookRunRoute(playbookRunID)}/update-status-dialog`,
|
||||||
|
{method: 'post', body},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Checklist Management
|
// Checklist Management
|
||||||
setChecklistItemState = async (playbookRunID: string, checklistNum: number, itemNum: number, newState: ChecklistItemState) => {
|
setChecklistItemState = async (playbookRunID: string, checklistNum: number, itemNum: number, newState: ChecklistItemState) => {
|
||||||
await this.doFetch(
|
await this.doFetch(
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import PlaybooksButton from './playbooks_button';
|
||||||
|
|
||||||
|
export default PlaybooksButton;
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import {fireEvent, renderWithIntl} from '@test/intl-test-helper';
|
||||||
|
|
||||||
|
import PlaybooksButton from './playbooks_button';
|
||||||
|
|
||||||
|
jest.mock('@hooks/utils', () => ({
|
||||||
|
usePreventDoubleTap: jest.fn((callback) => callback),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@playbooks/screens/navigation', () => ({
|
||||||
|
goToParticipantPlaybooks: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('PlaybooksButton', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders correctly with default props', () => {
|
||||||
|
const {getByTestId, getByText} = renderWithIntl(<PlaybooksButton/>);
|
||||||
|
|
||||||
|
const icon = getByTestId('channel_list.playbooks.button-icon');
|
||||||
|
expect(icon).toHaveProp('name', 'product-playbooks');
|
||||||
|
expect(getByText('Playbook runs')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls goToParticipantPlaybooks when pressed', () => {
|
||||||
|
const {goToParticipantPlaybooks} = require('@playbooks/screens/navigation');
|
||||||
|
const {getByTestId} = renderWithIntl(<PlaybooksButton/>);
|
||||||
|
|
||||||
|
const button = getByTestId('channel_list.playbooks.button');
|
||||||
|
fireEvent.press(button);
|
||||||
|
|
||||||
|
expect(goToParticipantPlaybooks).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import React, {useCallback, useMemo} from 'react';
|
||||||
|
import {useIntl} from 'react-intl';
|
||||||
|
import {TouchableOpacity, View} from 'react-native';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getStyleSheet as getChannelItemStyleSheet,
|
||||||
|
ROW_HEIGHT,
|
||||||
|
textStyle as channelItemTextStyle,
|
||||||
|
} from '@components/channel_item/channel_item';
|
||||||
|
import CompassIcon from '@components/compass_icon';
|
||||||
|
import FormattedText from '@components/formatted_text';
|
||||||
|
import {HOME_PADDING} from '@constants/view';
|
||||||
|
import {useTheme} from '@context/theme';
|
||||||
|
import {usePreventDoubleTap} from '@hooks/utils';
|
||||||
|
import {goToParticipantPlaybooks} from '@playbooks/screens/navigation';
|
||||||
|
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||||
|
|
||||||
|
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
||||||
|
icon: {
|
||||||
|
color: changeOpacity(theme.sidebarText, 0.5),
|
||||||
|
fontSize: 24,
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
iconActive: {
|
||||||
|
color: theme.sidebarText,
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const PlaybooksButton = () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const theme = useTheme();
|
||||||
|
const commonChannelItemStyles = getChannelItemStyleSheet(theme);
|
||||||
|
const styles = getStyleSheet(theme);
|
||||||
|
|
||||||
|
const handlePress = usePreventDoubleTap(useCallback(() => {
|
||||||
|
goToParticipantPlaybooks(intl);
|
||||||
|
}, [intl]));
|
||||||
|
|
||||||
|
const [containerStyle, iconStyle, textStyle] = useMemo(() => {
|
||||||
|
const container = [
|
||||||
|
commonChannelItemStyles.container,
|
||||||
|
HOME_PADDING,
|
||||||
|
{minHeight: ROW_HEIGHT},
|
||||||
|
];
|
||||||
|
|
||||||
|
const icon = [
|
||||||
|
styles.icon,
|
||||||
|
];
|
||||||
|
|
||||||
|
const text = [
|
||||||
|
styles.text,
|
||||||
|
channelItemTextStyle.regular,
|
||||||
|
commonChannelItemStyles.text,
|
||||||
|
];
|
||||||
|
|
||||||
|
return [container, icon, text];
|
||||||
|
}, [styles, commonChannelItemStyles]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handlePress}
|
||||||
|
testID='channel_list.playbooks.button'
|
||||||
|
>
|
||||||
|
<View style={containerStyle}>
|
||||||
|
<CompassIcon
|
||||||
|
name='product-playbooks'
|
||||||
|
style={iconStyle}
|
||||||
|
testID='channel_list.playbooks.button-icon'
|
||||||
|
/>
|
||||||
|
<FormattedText
|
||||||
|
id='playbooks.home_button.title'
|
||||||
|
defaultMessage='Playbook runs'
|
||||||
|
style={textStyle}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default React.memo(PlaybooksButton);
|
||||||
|
|
@ -2,15 +2,23 @@
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
export const PLAYBOOKS_RUNS = 'PlaybookRuns';
|
export const PLAYBOOKS_RUNS = 'PlaybookRuns';
|
||||||
|
export const PARTICIPANT_PLAYBOOKS = 'ParticipantPlaybooks';
|
||||||
export const PLAYBOOK_RUN = 'PlaybookRun';
|
export const PLAYBOOK_RUN = 'PlaybookRun';
|
||||||
export const PLAYBOOK_EDIT_COMMAND = 'PlaybookEditCommand';
|
export const PLAYBOOK_EDIT_COMMAND = 'PlaybookEditCommand';
|
||||||
|
export const PLAYBOOK_POST_UPDATE = 'PlaybookPostUpdate';
|
||||||
export const PLAYBOOK_SELECT_USER = 'PlaybookSelectUser';
|
export const PLAYBOOK_SELECT_USER = 'PlaybookSelectUser';
|
||||||
export const PLAYBOOKS_SELECT_DATE = 'PlaybooksSelectDate';
|
export const PLAYBOOKS_SELECT_DATE = 'PlaybooksSelectDate';
|
||||||
|
export const PLAYBOOKS_SELECT_PLAYBOOK = 'PlaybooksSelectPlaybook';
|
||||||
|
export const PLAYBOOKS_START_A_RUN = 'PlaybooksStartARun';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
PLAYBOOKS_RUNS,
|
PLAYBOOKS_RUNS,
|
||||||
|
PARTICIPANT_PLAYBOOKS,
|
||||||
PLAYBOOK_RUN,
|
PLAYBOOK_RUN,
|
||||||
PLAYBOOK_EDIT_COMMAND,
|
PLAYBOOK_EDIT_COMMAND,
|
||||||
|
PLAYBOOK_POST_UPDATE,
|
||||||
PLAYBOOK_SELECT_USER,
|
PLAYBOOK_SELECT_USER,
|
||||||
PLAYBOOKS_SELECT_DATE,
|
PLAYBOOKS_SELECT_DATE,
|
||||||
};
|
PLAYBOOKS_SELECT_PLAYBOOK,
|
||||||
|
PLAYBOOKS_START_A_RUN,
|
||||||
|
} as const;
|
||||||
|
|
|
||||||
|
|
@ -75,71 +75,4 @@ describe('shouldHandlePlaybookChecklistItemRecord', () => {
|
||||||
|
|
||||||
expect(shouldHandlePlaybookChecklistItemRecord(existingRecord, raw)).toBe(true);
|
expect(shouldHandlePlaybookChecklistItemRecord(existingRecord, raw)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return true when condition_action changes even if update_at is the same', () => {
|
|
||||||
const existingRecord = TestHelper.fakePlaybookChecklistItemModel({
|
|
||||||
updateAt: 112233,
|
|
||||||
conditionAction: '',
|
|
||||||
});
|
|
||||||
const raw = TestHelper.fakePlaybookChecklistItem(existingRecord.checklistId, {
|
|
||||||
update_at: 112233,
|
|
||||||
condition_action: 'hidden',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(shouldHandlePlaybookChecklistItemRecord(existingRecord, raw)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return true when condition_reason changes even if update_at is the same', () => {
|
|
||||||
const existingRecord = TestHelper.fakePlaybookChecklistItemModel({
|
|
||||||
updateAt: 112233,
|
|
||||||
conditionReason: '',
|
|
||||||
});
|
|
||||||
const raw = TestHelper.fakePlaybookChecklistItem(existingRecord.checklistId, {
|
|
||||||
update_at: 112233,
|
|
||||||
condition_reason: 'Dependent task not completed',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(shouldHandlePlaybookChecklistItemRecord(existingRecord, raw)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false when condition fields are unchanged and update_at is the same', () => {
|
|
||||||
const existingRecord = TestHelper.fakePlaybookChecklistItemModel({
|
|
||||||
updateAt: 112233,
|
|
||||||
conditionAction: 'hidden',
|
|
||||||
conditionReason: 'Some reason',
|
|
||||||
});
|
|
||||||
const raw = TestHelper.fakePlaybookChecklistItem(existingRecord.checklistId, {
|
|
||||||
update_at: 112233,
|
|
||||||
condition_action: 'hidden',
|
|
||||||
condition_reason: 'Some reason',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(shouldHandlePlaybookChecklistItemRecord(existingRecord, raw)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return true when condition_action changes from hidden to shown_because_modified', () => {
|
|
||||||
const existingRecord = TestHelper.fakePlaybookChecklistItemModel({
|
|
||||||
updateAt: 112233,
|
|
||||||
conditionAction: 'hidden',
|
|
||||||
});
|
|
||||||
const raw = TestHelper.fakePlaybookChecklistItem(existingRecord.checklistId, {
|
|
||||||
update_at: 112233,
|
|
||||||
condition_action: 'shown_because_modified',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(shouldHandlePlaybookChecklistItemRecord(existingRecord, raw)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false when condition_action is undefined in raw data', () => {
|
|
||||||
const existingRecord = TestHelper.fakePlaybookChecklistItemModel({
|
|
||||||
updateAt: 112233,
|
|
||||||
conditionAction: 'hidden',
|
|
||||||
});
|
|
||||||
const raw = TestHelper.fakePlaybookChecklistItem(existingRecord.checklistId, {
|
|
||||||
update_at: 112233,
|
|
||||||
});
|
|
||||||
delete raw.condition_action;
|
|
||||||
|
|
||||||
expect(shouldHandlePlaybookChecklistItemRecord(existingRecord, raw)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -14,16 +14,5 @@ export const shouldHandlePlaybookChecklistRecord = (existingRecord: PlaybookChec
|
||||||
};
|
};
|
||||||
|
|
||||||
export const shouldHandlePlaybookChecklistItemRecord = (existingRecord: PlaybookChecklistItemModel, raw: PartialChecklistItem): boolean => {
|
export const shouldHandlePlaybookChecklistItemRecord = (existingRecord: PlaybookChecklistItemModel, raw: PartialChecklistItem): boolean => {
|
||||||
// Check if update_at has changed (primary check)
|
return Boolean(existingRecord.updateAt !== raw.update_at);
|
||||||
if (existingRecord.updateAt !== raw.update_at) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if condition fields have changed (allows updates without update_at change)
|
|
||||||
const conditionActionChanged = raw.condition_action !== undefined &&
|
|
||||||
existingRecord.conditionAction !== raw.condition_action;
|
|
||||||
const conditionReasonChanged = raw.condition_reason !== undefined &&
|
|
||||||
existingRecord.conditionReason !== raw.condition_reason;
|
|
||||||
|
|
||||||
return Boolean(conditionActionChanged || conditionReasonChanged);
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import TestHelper from '@test/test_helper';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
queryPlaybookRunsPerChannel,
|
queryPlaybookRunsPerChannel,
|
||||||
|
queryPlaybookRunsByParticipant,
|
||||||
getPlaybookRunById,
|
getPlaybookRunById,
|
||||||
observePlaybookRunById,
|
observePlaybookRunById,
|
||||||
observePlaybookRunProgress,
|
observePlaybookRunProgress,
|
||||||
|
|
@ -625,4 +626,85 @@ describe('Playbook Run Queries', () => {
|
||||||
expect(subscriptionNext).toHaveBeenCalledWith([]);
|
expect(subscriptionNext).toHaveBeenCalledWith([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('queryPlaybookRunsByParticipant', () => {
|
||||||
|
it('should query playbook runs by participant ID', async () => {
|
||||||
|
const participantId = 'participant-id-1';
|
||||||
|
const mockRuns = TestHelper.createPlaybookRuns(2, 0, 0).map((run, index) => ({
|
||||||
|
...run,
|
||||||
|
participant_ids: [participantId, 'other-participant'],
|
||||||
|
id: `run-${index}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await operator.handlePlaybookRun({
|
||||||
|
runs: mockRuns,
|
||||||
|
prepareRecordsOnly: false,
|
||||||
|
removeAssociatedRecords: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = queryPlaybookRunsByParticipant(operator.database, participantId);
|
||||||
|
const fetchedRuns = await result.fetch();
|
||||||
|
|
||||||
|
expect(fetchedRuns.length).toBe(2);
|
||||||
|
const runIds = fetchedRuns.map((run) => run.id);
|
||||||
|
expect(runIds).toContain(mockRuns[0].id);
|
||||||
|
expect(runIds).toContain(mockRuns[1].id);
|
||||||
|
|
||||||
|
// Verify the runs are sorted by create_at desc
|
||||||
|
expect(fetchedRuns[0].createAt).toBeGreaterThanOrEqual(fetchedRuns[1].createAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array when no runs match participant ID', async () => {
|
||||||
|
const participantId = 'participant-id-1';
|
||||||
|
const nonMatchingParticipantId = 'other-participant';
|
||||||
|
const mockRuns = TestHelper.createPlaybookRuns(1, 0, 0).map((run) => ({
|
||||||
|
...run,
|
||||||
|
participant_ids: [nonMatchingParticipantId],
|
||||||
|
}));
|
||||||
|
|
||||||
|
await operator.handlePlaybookRun({
|
||||||
|
runs: mockRuns,
|
||||||
|
prepareRecordsOnly: false,
|
||||||
|
removeAssociatedRecords: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = queryPlaybookRunsByParticipant(operator.database, participantId);
|
||||||
|
const fetchedRuns = await result.fetch();
|
||||||
|
|
||||||
|
expect(fetchedRuns.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle JSON array participant_ids properly', async () => {
|
||||||
|
const participantId = 'user123';
|
||||||
|
const mockRuns = TestHelper.createPlaybookRuns(3, 0, 0).map((run, index) => ({
|
||||||
|
...run,
|
||||||
|
participant_ids: (() => {
|
||||||
|
if (index === 0) {
|
||||||
|
return [participantId];
|
||||||
|
}
|
||||||
|
if (index === 1) {
|
||||||
|
return ['other', participantId, 'another'];
|
||||||
|
}
|
||||||
|
return ['different'];
|
||||||
|
})(),
|
||||||
|
id: `run-${index}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await operator.handlePlaybookRun({
|
||||||
|
runs: mockRuns,
|
||||||
|
prepareRecordsOnly: false,
|
||||||
|
removeAssociatedRecords: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = queryPlaybookRunsByParticipant(operator.database, participantId);
|
||||||
|
const fetchedRuns = await result.fetch();
|
||||||
|
|
||||||
|
// Should only return the first two runs (index 0 and 1)
|
||||||
|
expect(fetchedRuns.length).toBe(2);
|
||||||
|
const runIds = fetchedRuns.map((run) => run.id);
|
||||||
|
expect(runIds).toContain(mockRuns[0].id);
|
||||||
|
expect(runIds).toContain(mockRuns[1].id);
|
||||||
|
expect(runIds).not.toContain(mockRuns[2].id);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -88,3 +88,10 @@ export const observeParticipantsIdsFromPlaybookModel = (runModel: PlaybookRunMod
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const queryPlaybookRunsByParticipant = (database: Database, participantId: string) => {
|
||||||
|
return database.get<PlaybookRunModel>(PLAYBOOK_RUN).query(
|
||||||
|
Q.where('participant_ids', Q.like(`%"${participantId}"%`)),
|
||||||
|
Q.sortBy('create_at', 'desc'),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,14 @@ import Screens from '@playbooks/constants/screens';
|
||||||
import {render} from '@test/intl-test-helper';
|
import {render} from '@test/intl-test-helper';
|
||||||
|
|
||||||
import EditCommand from './edit_command';
|
import EditCommand from './edit_command';
|
||||||
|
import ParticipantPlaybooks from './participant_playbooks';
|
||||||
import PlaybookRun from './playbook_run';
|
import PlaybookRun from './playbook_run';
|
||||||
import PlaybookRuns from './playbooks_runs';
|
import PlaybookRuns from './playbooks_runs';
|
||||||
|
import PostUpdate from './post_update';
|
||||||
import SelectDate from './select_date';
|
import SelectDate from './select_date';
|
||||||
|
import SelectPlaybook from './select_playbook';
|
||||||
import SelectUser from './select_user';
|
import SelectUser from './select_user';
|
||||||
|
import StartARun from './start_a_run';
|
||||||
|
|
||||||
import {loadPlaybooksScreen} from '.';
|
import {loadPlaybooksScreen} from '.';
|
||||||
|
|
||||||
|
|
@ -52,6 +56,30 @@ jest.mock('@playbooks/screens/select_date', () => ({
|
||||||
}));
|
}));
|
||||||
jest.mocked(SelectDate).mockImplementation((props) => <Text {...props}>{Screens.PLAYBOOKS_SELECT_DATE}</Text>);
|
jest.mocked(SelectDate).mockImplementation((props) => <Text {...props}>{Screens.PLAYBOOKS_SELECT_DATE}</Text>);
|
||||||
|
|
||||||
|
jest.mock('@playbooks/screens/start_a_run', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: jest.fn(),
|
||||||
|
}));
|
||||||
|
jest.mocked(StartARun).mockImplementation((props) => <Text {...props}>{Screens.PLAYBOOKS_START_A_RUN}</Text>);
|
||||||
|
|
||||||
|
jest.mock('@playbooks/screens/select_playbook', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: jest.fn(),
|
||||||
|
}));
|
||||||
|
jest.mocked(SelectPlaybook).mockImplementation((props) => <Text {...props}>{Screens.PLAYBOOKS_SELECT_PLAYBOOK}</Text>);
|
||||||
|
|
||||||
|
jest.mock('@playbooks/screens/participant_playbooks', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: jest.fn(),
|
||||||
|
}));
|
||||||
|
jest.mocked(ParticipantPlaybooks).mockImplementation((props) => <Text {...props}>{Screens.PARTICIPANT_PLAYBOOKS}</Text>);
|
||||||
|
|
||||||
|
jest.mock('@playbooks/screens/post_update', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: jest.fn(),
|
||||||
|
}));
|
||||||
|
jest.mocked(PostUpdate).mockImplementation((props) => <Text {...props}>{Screens.PLAYBOOK_POST_UPDATE}</Text>);
|
||||||
|
|
||||||
describe('Screen Registration', () => {
|
describe('Screen Registration', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,22 @@ export function loadPlaybooksScreen(screenName: string | number) {
|
||||||
switch (screenName) {
|
switch (screenName) {
|
||||||
case Screens.PLAYBOOKS_RUNS:
|
case Screens.PLAYBOOKS_RUNS:
|
||||||
return withServerDatabase(require('@playbooks/screens/playbooks_runs').default);
|
return withServerDatabase(require('@playbooks/screens/playbooks_runs').default);
|
||||||
|
case Screens.PARTICIPANT_PLAYBOOKS:
|
||||||
|
return withServerDatabase(require('@playbooks/screens/participant_playbooks').default);
|
||||||
case Screens.PLAYBOOK_RUN:
|
case Screens.PLAYBOOK_RUN:
|
||||||
return withServerDatabase(require('@playbooks/screens/playbook_run').default);
|
return withServerDatabase(require('@playbooks/screens/playbook_run').default);
|
||||||
case Screens.PLAYBOOK_EDIT_COMMAND:
|
case Screens.PLAYBOOK_EDIT_COMMAND:
|
||||||
return withServerDatabase(require('@playbooks/screens/edit_command').default);
|
return withServerDatabase(require('@playbooks/screens/edit_command').default);
|
||||||
|
case Screens.PLAYBOOK_POST_UPDATE:
|
||||||
|
return withServerDatabase(require('@playbooks/screens/post_update').default);
|
||||||
case Screens.PLAYBOOK_SELECT_USER:
|
case Screens.PLAYBOOK_SELECT_USER:
|
||||||
return withServerDatabase(require('@playbooks/screens/select_user').default);
|
return withServerDatabase(require('@playbooks/screens/select_user').default);
|
||||||
case Screens.PLAYBOOKS_SELECT_DATE:
|
case Screens.PLAYBOOKS_SELECT_DATE:
|
||||||
return withServerDatabase(require('@playbooks/screens/select_date').default);
|
return withServerDatabase(require('@playbooks/screens/select_date').default);
|
||||||
|
case Screens.PLAYBOOKS_SELECT_PLAYBOOK:
|
||||||
|
return withServerDatabase(require('@playbooks/screens/select_playbook').default);
|
||||||
|
case Screens.PLAYBOOKS_START_A_RUN:
|
||||||
|
return withServerDatabase(require('@playbooks/screens/start_a_run').default);
|
||||||
default:
|
default:
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,23 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import {joinIfNeededAndSwitchToChannel} from '@actions/remote/channel';
|
||||||
import {Preferences, Screens} from '@constants';
|
import {Preferences, Screens} from '@constants';
|
||||||
import {goToScreen} from '@screens/navigation';
|
import {goToScreen} from '@screens/navigation';
|
||||||
import TestHelper from '@test/test_helper';
|
import TestHelper from '@test/test_helper';
|
||||||
import {changeOpacity} from '@utils/theme';
|
import {changeOpacity} from '@utils/theme';
|
||||||
|
|
||||||
import {goToPlaybookRuns, goToPlaybookRun, goToEditCommand, goToSelectUser, goToSelectDate} from './navigation';
|
import {goToPlaybookRuns, goToPlaybookRun, goToParticipantPlaybooks, goToPlaybookRunWithChannelSwitch, goToEditCommand, goToSelectUser, goToSelectDate} from './navigation';
|
||||||
|
|
||||||
jest.mock('@screens/navigation', () => ({
|
jest.mock('@screens/navigation', () => ({
|
||||||
goToScreen: jest.fn(),
|
goToScreen: jest.fn(),
|
||||||
getThemeFromState: jest.fn(() => require('@constants').Preferences.THEMES.denim),
|
getThemeFromState: jest.fn(() => require('@constants').Preferences.THEMES.denim),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
jest.mock('@actions/remote/channel', () => ({
|
||||||
|
joinIfNeededAndSwitchToChannel: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
describe('Playbooks Navigation', () => {
|
describe('Playbooks Navigation', () => {
|
||||||
const mockIntl = TestHelper.fakeIntl();
|
const mockIntl = TestHelper.fakeIntl();
|
||||||
|
|
||||||
|
|
@ -184,4 +189,55 @@ describe('Playbooks Navigation', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('goToParticipantPlaybooks', () => {
|
||||||
|
it('should navigate to participant playbooks screen with correct parameters', () => {
|
||||||
|
goToParticipantPlaybooks(mockIntl);
|
||||||
|
|
||||||
|
expect(mockIntl.formatMessage).toHaveBeenCalledWith({
|
||||||
|
id: 'playbooks.participant_playbooks.title',
|
||||||
|
defaultMessage: 'Playbook runs',
|
||||||
|
});
|
||||||
|
expect(goToScreen).toHaveBeenCalledWith(
|
||||||
|
Screens.PARTICIPANT_PLAYBOOKS,
|
||||||
|
'Playbook runs',
|
||||||
|
{},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('goToPlaybookRunWithChannelSwitch', () => {
|
||||||
|
it('should switch to channel first and then navigate to playbook run', async () => {
|
||||||
|
const serverUrl = 'https://test.server.com';
|
||||||
|
const mockPlaybookRun = TestHelper.fakePlaybookRun({
|
||||||
|
id: 'run-id-1',
|
||||||
|
channel_id: 'channel-id-1',
|
||||||
|
team_id: 'team-id-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mocked(joinIfNeededAndSwitchToChannel).mockResolvedValue({});
|
||||||
|
|
||||||
|
await goToPlaybookRunWithChannelSwitch(mockIntl, serverUrl, mockPlaybookRun);
|
||||||
|
|
||||||
|
expect(joinIfNeededAndSwitchToChannel).toHaveBeenCalledWith(
|
||||||
|
serverUrl,
|
||||||
|
{id: mockPlaybookRun.channel_id},
|
||||||
|
{id: mockPlaybookRun.team_id},
|
||||||
|
expect.any(Function),
|
||||||
|
mockIntl,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockIntl.formatMessage).toHaveBeenCalledWith({
|
||||||
|
id: 'playbooks.playbook_run.title',
|
||||||
|
defaultMessage: 'Playbook run',
|
||||||
|
});
|
||||||
|
expect(goToScreen).toHaveBeenCalledWith(
|
||||||
|
Screens.PLAYBOOK_RUN,
|
||||||
|
'Playbook run',
|
||||||
|
{playbookRunId: mockPlaybookRun.id},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,14 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import {joinIfNeededAndSwitchToChannel} from '@actions/remote/channel';
|
||||||
import {Screens} from '@constants';
|
import {Screens} from '@constants';
|
||||||
import {getThemeFromState, goToScreen} from '@screens/navigation';
|
import {getThemeFromState, goToScreen} from '@screens/navigation';
|
||||||
|
import {errorBadChannel} from '@utils/draft';
|
||||||
|
import {logDebug} from '@utils/log';
|
||||||
import {changeOpacity} from '@utils/theme';
|
import {changeOpacity} from '@utils/theme';
|
||||||
|
|
||||||
|
import type PlaybookRunModel from '@playbooks/types/database/models/playbook_run';
|
||||||
import type {IntlShape} from 'react-intl';
|
import type {IntlShape} from 'react-intl';
|
||||||
import type {Options as RNNOptions} from 'react-native-navigation';
|
import type {Options as RNNOptions} from 'react-native-navigation';
|
||||||
|
|
||||||
|
|
@ -21,11 +25,37 @@ export function goToPlaybookRuns(intl: IntlShape, channelId: string, channelName
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function goToParticipantPlaybooks(intl: IntlShape) {
|
||||||
|
const title = intl.formatMessage({id: 'playbooks.participant_playbooks.title', defaultMessage: 'Playbook runs'});
|
||||||
|
goToScreen(Screens.PARTICIPANT_PLAYBOOKS, title, {}, {});
|
||||||
|
}
|
||||||
|
|
||||||
export async function goToPlaybookRun(intl: IntlShape, playbookRunId: string, playbookRun?: PlaybookRun) {
|
export async function goToPlaybookRun(intl: IntlShape, playbookRunId: string, playbookRun?: PlaybookRun) {
|
||||||
const title = intl.formatMessage({id: 'playbooks.playbook_run.title', defaultMessage: 'Playbook run'});
|
const title = intl.formatMessage({id: 'playbooks.playbook_run.title', defaultMessage: 'Playbook run'});
|
||||||
goToScreen(Screens.PLAYBOOK_RUN, title, {playbookRunId, playbookRun}, {});
|
goToScreen(Screens.PLAYBOOK_RUN, title, {playbookRunId, playbookRun}, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function goToPlaybookRunWithChannelSwitch(intl: IntlShape, serverUrl: string, playbookRun: PlaybookRun | PlaybookRunModel) {
|
||||||
|
const channelId = 'channelId' in playbookRun ? playbookRun.channelId : playbookRun.channel_id;
|
||||||
|
const teamId = 'teamId' in playbookRun ? playbookRun.teamId : playbookRun.team_id;
|
||||||
|
|
||||||
|
// First switch to the channel
|
||||||
|
const result = await joinIfNeededAndSwitchToChannel(serverUrl, {id: channelId}, {id: teamId}, errorBadChannel, intl);
|
||||||
|
if (result.error) {
|
||||||
|
logDebug('Failed to switch to channel', result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then navigate to the playbook run
|
||||||
|
const title = intl.formatMessage({id: 'playbooks.playbook_run.title', defaultMessage: 'Playbook run'});
|
||||||
|
goToScreen(Screens.PLAYBOOK_RUN, title, {playbookRunId: playbookRun.id}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function goToPostUpdate(intl: IntlShape, playbookRunId: string) {
|
||||||
|
const title = intl.formatMessage({id: 'playbooks.post_update.title', defaultMessage: 'Post update'});
|
||||||
|
goToScreen(Screens.PLAYBOOK_POST_UPDATE, title, {playbookRunId}, {});
|
||||||
|
}
|
||||||
|
|
||||||
function getSubtitleOptions(theme: Theme, runName: string): RNNOptions {
|
function getSubtitleOptions(theme: Theme, runName: string): RNNOptions {
|
||||||
return {
|
return {
|
||||||
topBar: {
|
topBar: {
|
||||||
|
|
@ -86,3 +116,32 @@ export async function goToSelectDate(
|
||||||
selectedDate,
|
selectedDate,
|
||||||
}, options);
|
}, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function goToSelectPlaybook(
|
||||||
|
intl: IntlShape,
|
||||||
|
theme: Theme,
|
||||||
|
channelId?: string,
|
||||||
|
) {
|
||||||
|
const title = intl.formatMessage({id: 'playbooks.select_playbook.title', defaultMessage: 'Start a run'});
|
||||||
|
goToScreen(Screens.PLAYBOOKS_SELECT_PLAYBOOK, title, {channelId}, {
|
||||||
|
topBar: {
|
||||||
|
subtitle: {
|
||||||
|
text: intl.formatMessage({id: 'playbooks.select_playbook.subtitle', defaultMessage: 'Select a playbook'}),
|
||||||
|
color: changeOpacity(theme.sidebarText, 0.72),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function goToStartARun(intl: IntlShape, theme: Theme, playbook: Playbook, onRunCreated: (run: PlaybookRun) => void, channelId?: string) {
|
||||||
|
const title = intl.formatMessage({id: 'playbooks.start_a_run.title', defaultMessage: 'Start a run'});
|
||||||
|
const subtitle = playbook.title;
|
||||||
|
goToScreen(Screens.PLAYBOOKS_START_A_RUN, title, {playbook, onRunCreated, channelId}, {
|
||||||
|
topBar: {
|
||||||
|
subtitle: {
|
||||||
|
text: subtitle,
|
||||||
|
color: changeOpacity(theme.sidebarText, 0.72),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,171 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import {SYSTEM_IDENTIFIERS} from '@constants/database';
|
||||||
|
import DatabaseManager from '@database/manager';
|
||||||
|
import {act, renderWithEverything, waitFor} from '@test/intl-test-helper';
|
||||||
|
import TestHelper from '@test/test_helper';
|
||||||
|
|
||||||
|
import ParticipantPlaybooks from './participant_playbooks';
|
||||||
|
|
||||||
|
import ParticipantPlaybooksIndex from './index';
|
||||||
|
|
||||||
|
import type ServerDataOperator from '@database/operator/server_data_operator';
|
||||||
|
import type {Database} from '@nozbe/watermelondb';
|
||||||
|
import type {PlaybookRunModel} from '@playbooks/database/models';
|
||||||
|
|
||||||
|
// Mock the ParticipantPlaybooks component
|
||||||
|
jest.mock('./participant_playbooks');
|
||||||
|
jest.mocked(ParticipantPlaybooks).mockImplementation((props) => {
|
||||||
|
return React.createElement('ParticipantPlaybooks', {
|
||||||
|
testID: 'participant-playbooks',
|
||||||
|
...props,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ParticipantPlaybooks Index', () => {
|
||||||
|
const serverUrl = 'server-url';
|
||||||
|
const currentUserId = 'current-user-id';
|
||||||
|
|
||||||
|
let database: Database;
|
||||||
|
let operator: ServerDataOperator;
|
||||||
|
|
||||||
|
const componentId = 'ParticipantPlaybooks' as const;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await DatabaseManager.init([serverUrl]);
|
||||||
|
const serverDatabaseAndOperator = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
||||||
|
database = serverDatabaseAndOperator.database;
|
||||||
|
operator = serverDatabaseAndOperator.operator;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
DatabaseManager.destroyServerDatabase(serverUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders ParticipantPlaybooks component with no data', () => {
|
||||||
|
const props = {
|
||||||
|
componentId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const {getByTestId} = renderWithEverything(
|
||||||
|
<ParticipantPlaybooksIndex {...props}/>,
|
||||||
|
{database},
|
||||||
|
);
|
||||||
|
|
||||||
|
const component = getByTestId('participant-playbooks');
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
expect(component.props.componentId).toBe('ParticipantPlaybooks');
|
||||||
|
expect(component.props.currentUserId).toBe('');
|
||||||
|
expect(component.props.cachedPlaybookRuns).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders ParticipantPlaybooks component with current user data', async () => {
|
||||||
|
await operator.handleSystem({
|
||||||
|
systems: [{
|
||||||
|
id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID,
|
||||||
|
value: currentUserId,
|
||||||
|
}],
|
||||||
|
prepareRecordsOnly: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const runs = TestHelper.createPlaybookRuns(4, 1, 1);
|
||||||
|
runs[0].participant_ids = [currentUserId];
|
||||||
|
runs[1].participant_ids = ['other-user-id'];
|
||||||
|
runs[2].participant_ids = [currentUserId];
|
||||||
|
runs[3].participant_ids = ['other-user-id'];
|
||||||
|
|
||||||
|
await operator.handlePlaybookRun({
|
||||||
|
prepareRecordsOnly: false,
|
||||||
|
runs,
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = {
|
||||||
|
componentId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const {getByTestId} = renderWithEverything(
|
||||||
|
<ParticipantPlaybooksIndex {...props}/>,
|
||||||
|
{database},
|
||||||
|
);
|
||||||
|
|
||||||
|
const component = getByTestId('participant-playbooks');
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
expect(component.props.componentId).toBe('ParticipantPlaybooks');
|
||||||
|
expect(component.props.currentUserId).toBe(currentUserId);
|
||||||
|
expect(component.props.cachedPlaybookRuns).toHaveLength(2);
|
||||||
|
const runIds = component.props.cachedPlaybookRuns.map((r: PlaybookRunModel) => r.id);
|
||||||
|
expect(runIds).toContain(runs[0].id);
|
||||||
|
expect(runIds).toContain(runs[2].id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reacts to current user changes', async () => {
|
||||||
|
const props = {
|
||||||
|
componentId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const otherUserId = 'other-user-id';
|
||||||
|
const runs = TestHelper.createPlaybookRuns(4, 1, 1);
|
||||||
|
runs[0].participant_ids = [currentUserId];
|
||||||
|
runs[1].participant_ids = [otherUserId];
|
||||||
|
runs[2].participant_ids = [currentUserId];
|
||||||
|
runs[3].participant_ids = [otherUserId];
|
||||||
|
await operator.handlePlaybookRun({
|
||||||
|
prepareRecordsOnly: false,
|
||||||
|
runs,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {getByTestId} = renderWithEverything(
|
||||||
|
<ParticipantPlaybooksIndex {...props}/>,
|
||||||
|
{database},
|
||||||
|
);
|
||||||
|
|
||||||
|
let component = getByTestId('participant-playbooks');
|
||||||
|
expect(component.props.currentUserId).toBe('');
|
||||||
|
expect(component.props.cachedPlaybookRuns).toEqual([]);
|
||||||
|
|
||||||
|
// Add current user to database
|
||||||
|
await act(async () => {
|
||||||
|
await operator.handleSystem({
|
||||||
|
systems: [{
|
||||||
|
id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID,
|
||||||
|
value: currentUserId,
|
||||||
|
}],
|
||||||
|
prepareRecordsOnly: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Component should react to the change
|
||||||
|
await waitFor(() => {
|
||||||
|
component = getByTestId('participant-playbooks');
|
||||||
|
expect(component.props.currentUserId).toBe(currentUserId);
|
||||||
|
expect(component.props.cachedPlaybookRuns).toHaveLength(2);
|
||||||
|
const runIds = component.props.cachedPlaybookRuns.map((r: PlaybookRunModel) => r.id);
|
||||||
|
expect(runIds).toContain(runs[0].id);
|
||||||
|
expect(runIds).toContain(runs[2].id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add current user to database
|
||||||
|
await act(async () => {
|
||||||
|
await operator.handleSystem({
|
||||||
|
systems: [{
|
||||||
|
id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID,
|
||||||
|
value: otherUserId,
|
||||||
|
}],
|
||||||
|
prepareRecordsOnly: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Component should react to the change
|
||||||
|
await waitFor(() => {
|
||||||
|
component = getByTestId('participant-playbooks');
|
||||||
|
expect(component.props.currentUserId).toBe(otherUserId);
|
||||||
|
expect(component.props.cachedPlaybookRuns).toHaveLength(2);
|
||||||
|
const runIds = component.props.cachedPlaybookRuns.map((r: PlaybookRunModel) => r.id);
|
||||||
|
expect(runIds).toContain(runs[1].id);
|
||||||
|
expect(runIds).toContain(runs[3].id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
|
||||||
|
import {of} from 'rxjs';
|
||||||
|
import {switchMap} from 'rxjs/operators';
|
||||||
|
|
||||||
|
import {queryPlaybookRunsByParticipant} from '@playbooks/database/queries/run';
|
||||||
|
import {observeCurrentUserId} from '@queries/servers/system';
|
||||||
|
|
||||||
|
import ParticipantPlaybooks from './participant_playbooks';
|
||||||
|
|
||||||
|
import type {WithDatabaseArgs} from '@typings/database/database';
|
||||||
|
|
||||||
|
type OwnProps = WithDatabaseArgs;
|
||||||
|
|
||||||
|
const enhanced = withObservables([], ({database}: OwnProps) => {
|
||||||
|
const currentUserId = observeCurrentUserId(database);
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentUserId,
|
||||||
|
cachedPlaybookRuns: currentUserId.pipe(
|
||||||
|
switchMap((userId) =>
|
||||||
|
(userId ? queryPlaybookRunsByParticipant(database, userId).observe() : of([])),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export default withDatabase(enhanced(ParticipantPlaybooks));
|
||||||
|
|
@ -0,0 +1,237 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import React, {act, type ComponentProps} from 'react';
|
||||||
|
import {of as of$} from 'rxjs';
|
||||||
|
|
||||||
|
import DatabaseManager from '@database/manager';
|
||||||
|
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
|
||||||
|
import {fetchPlaybookRunsPageForParticipant} from '@playbooks/actions/remote/runs';
|
||||||
|
import {fireEvent, renderWithEverything, waitFor, waitForElementToBeRemoved} from '@test/intl-test-helper';
|
||||||
|
import TestHelper from '@test/test_helper';
|
||||||
|
|
||||||
|
import ParticipantPlaybooks from './participant_playbooks';
|
||||||
|
|
||||||
|
import type {Database} from '@nozbe/watermelondb';
|
||||||
|
|
||||||
|
jest.mock('@hooks/android_back_handler');
|
||||||
|
jest.mock('@playbooks/actions/remote/runs');
|
||||||
|
|
||||||
|
type FetchPlaybookRunsPageForParticipantReturn = Awaited<ReturnType<typeof fetchPlaybookRunsPageForParticipant>>;
|
||||||
|
|
||||||
|
describe('ParticipantPlaybooks', () => {
|
||||||
|
function getBaseProps(): ComponentProps<typeof ParticipantPlaybooks> {
|
||||||
|
return {
|
||||||
|
currentUserId: 'test-user-id',
|
||||||
|
componentId: 'ParticipantPlaybooks',
|
||||||
|
cachedPlaybookRuns: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMockRuns(numberOfRuns: number, numberOfFinishedRuns: number): PlaybookRun[] {
|
||||||
|
const mockRuns: PlaybookRun[] = [];
|
||||||
|
for (let i = 0; i < numberOfRuns; i++) {
|
||||||
|
mockRuns.push(TestHelper.fakePlaybookRun({
|
||||||
|
id: `run-${i}`,
|
||||||
|
name: `Test Run ${i}`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = numberOfRuns; i < numberOfRuns + numberOfFinishedRuns; i++) {
|
||||||
|
mockRuns.push(TestHelper.fakePlaybookRun({
|
||||||
|
id: `finished-run-${i}`,
|
||||||
|
name: `Test Finished Run ${i}`,
|
||||||
|
current_status: 'Finished',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return mockRuns;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverUrl = 'server-url';
|
||||||
|
|
||||||
|
let database: Database;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await DatabaseManager.init([serverUrl]);
|
||||||
|
const serverDatabaseAndOperator = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
||||||
|
database = serverDatabaseAndOperator.database;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
DatabaseManager.destroyServerDatabase(serverUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders loading state initially', async () => {
|
||||||
|
const props = getBaseProps();
|
||||||
|
|
||||||
|
// Mock the fetch to return after a delay so we can check loading state
|
||||||
|
let resolvePromise: (value: FetchPlaybookRunsPageForParticipantReturn) => void;
|
||||||
|
const pendingPromise = new Promise<FetchPlaybookRunsPageForParticipantReturn>((resolve) => {
|
||||||
|
resolvePromise = resolve;
|
||||||
|
});
|
||||||
|
jest.mocked(fetchPlaybookRunsPageForParticipant).mockReturnValue(pendingPromise);
|
||||||
|
|
||||||
|
const {getByTestId, queryByTestId} = renderWithEverything(<ParticipantPlaybooks {...props}/>, {database, serverUrl});
|
||||||
|
|
||||||
|
expect(getByTestId('loading')).toBeTruthy();
|
||||||
|
|
||||||
|
// Check that fetch was called
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(fetchPlaybookRunsPageForParticipant).toHaveBeenCalledWith('server-url', 'test-user-id', 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getByTestId('loading')).toBeTruthy();
|
||||||
|
|
||||||
|
// Resolve the promise
|
||||||
|
act(() => {
|
||||||
|
resolvePromise!({runs: [], hasMore: false});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitForElementToBeRemoved(() => queryByTestId('loading'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders empty state when no runs available', async () => {
|
||||||
|
const props = getBaseProps();
|
||||||
|
jest.mocked(fetchPlaybookRunsPageForParticipant).mockResolvedValue({runs: [], hasMore: false});
|
||||||
|
|
||||||
|
const {queryByTestId, getByText} = renderWithEverything(<ParticipantPlaybooks {...props}/>, {database, serverUrl});
|
||||||
|
|
||||||
|
await waitForElementToBeRemoved(() => queryByTestId('loading'));
|
||||||
|
|
||||||
|
// Should show empty state instead of loading
|
||||||
|
expect(getByText('No in progress runs')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders error state when fetch fails', async () => {
|
||||||
|
const props = getBaseProps();
|
||||||
|
jest.mocked(fetchPlaybookRunsPageForParticipant).mockResolvedValue({error: 'Network error'});
|
||||||
|
|
||||||
|
const {queryByTestId, getByText} = renderWithEverything(<ParticipantPlaybooks {...props}/>, {database, serverUrl});
|
||||||
|
|
||||||
|
await waitForElementToBeRemoved(() => queryByTestId('loading'));
|
||||||
|
|
||||||
|
// Should show empty state instead of loading
|
||||||
|
expect(getByText('No in progress runs')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders playbook runs when data is loaded', async () => {
|
||||||
|
const props = getBaseProps();
|
||||||
|
const runs = getMockRuns(1, 1);
|
||||||
|
jest.mocked(fetchPlaybookRunsPageForParticipant).mockResolvedValue({runs, hasMore: false});
|
||||||
|
|
||||||
|
const {queryByTestId, getByText} = renderWithEverything(<ParticipantPlaybooks {...props}/>, {database, serverUrl});
|
||||||
|
|
||||||
|
await waitForElementToBeRemoved(() => queryByTestId('loading'));
|
||||||
|
|
||||||
|
expect(getByText('Test Run 0')).toBeVisible();
|
||||||
|
expect(queryByTestId('Test Run 1')).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not fetch data when currentUserId is not provided', () => {
|
||||||
|
const props = getBaseProps();
|
||||||
|
props.currentUserId = '';
|
||||||
|
jest.mocked(fetchPlaybookRunsPageForParticipant).mockResolvedValue({runs: [], hasMore: false});
|
||||||
|
|
||||||
|
renderWithEverything(<ParticipantPlaybooks {...props}/>, {database, serverUrl});
|
||||||
|
|
||||||
|
// Should not call the fetch function when no user ID
|
||||||
|
expect(fetchPlaybookRunsPageForParticipant).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles Android back button', () => {
|
||||||
|
const props = getBaseProps();
|
||||||
|
const {popTopScreen} = require('@screens/navigation');
|
||||||
|
|
||||||
|
renderWithEverything(<ParticipantPlaybooks {...props}/>, {database, serverUrl});
|
||||||
|
|
||||||
|
expect(useAndroidHardwareBackHandler).toHaveBeenCalledWith(props.componentId, expect.any(Function));
|
||||||
|
|
||||||
|
const closeHandler = jest.mocked(useAndroidHardwareBackHandler).mock.calls[0][1];
|
||||||
|
closeHandler();
|
||||||
|
|
||||||
|
expect(popTopScreen).toHaveBeenCalledWith(props.componentId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles pagination with hasMore=true', async () => {
|
||||||
|
const props = getBaseProps();
|
||||||
|
const runs = getMockRuns(10, 0);
|
||||||
|
jest.mocked(fetchPlaybookRunsPageForParticipant).mockResolvedValue({runs, hasMore: true});
|
||||||
|
|
||||||
|
const {queryByTestId, getByTestId} = renderWithEverything(<ParticipantPlaybooks {...props}/>, {database, serverUrl});
|
||||||
|
|
||||||
|
await waitForElementToBeRemoved(() => queryByTestId('loading'));
|
||||||
|
|
||||||
|
expect(fetchPlaybookRunsPageForParticipant).toHaveBeenCalledWith('server-url', 'test-user-id', 0);
|
||||||
|
|
||||||
|
// Should set hasMore state to false when the API response indicates no more data
|
||||||
|
expect(fetchPlaybookRunsPageForParticipant).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const list = getByTestId('runs.list');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent(list, 'onEndReached');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fetchPlaybookRunsPageForParticipant).toHaveBeenCalledWith('server-url', 'test-user-id', 1);
|
||||||
|
expect(fetchPlaybookRunsPageForParticipant).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles pagination with hasMore=false', async () => {
|
||||||
|
const props = getBaseProps();
|
||||||
|
const runs = getMockRuns(10, 0);
|
||||||
|
jest.mocked(fetchPlaybookRunsPageForParticipant).mockResolvedValue({runs, hasMore: false});
|
||||||
|
|
||||||
|
const {queryByTestId, getByTestId} = renderWithEverything(<ParticipantPlaybooks {...props}/>, {database, serverUrl});
|
||||||
|
|
||||||
|
await waitForElementToBeRemoved(() => queryByTestId('loading'));
|
||||||
|
|
||||||
|
expect(fetchPlaybookRunsPageForParticipant).toHaveBeenCalledWith('server-url', 'test-user-id', 0);
|
||||||
|
|
||||||
|
// Should set hasMore state to false when the API response indicates no more data
|
||||||
|
expect(fetchPlaybookRunsPageForParticipant).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const list = getByTestId('runs.list');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent(list, 'onEndReached');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fetchPlaybookRunsPageForParticipant).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show cached warning when API fails and database has data', async () => {
|
||||||
|
// Mock API failure
|
||||||
|
jest.mocked(fetchPlaybookRunsPageForParticipant).mockResolvedValue({
|
||||||
|
error: 'Network error',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock database returning cached data
|
||||||
|
const mockDatabaseRun = TestHelper.fakePlaybookRunModel({});
|
||||||
|
jest.mocked(mockDatabaseRun.observe).mockReturnValue(of$(mockDatabaseRun));
|
||||||
|
|
||||||
|
const props = getBaseProps();
|
||||||
|
props.cachedPlaybookRuns = [mockDatabaseRun];
|
||||||
|
|
||||||
|
const {getByText, queryByTestId} = renderWithEverything(<ParticipantPlaybooks {...props}/>, {database, serverUrl});
|
||||||
|
|
||||||
|
await waitForElementToBeRemoved(() => queryByTestId('loading'));
|
||||||
|
|
||||||
|
expect(getByText(/Showing cached data only/)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not show cached warning when API succeeds', async () => {
|
||||||
|
const props = getBaseProps();
|
||||||
|
const runs = getMockRuns(1, 1);
|
||||||
|
|
||||||
|
jest.mocked(fetchPlaybookRunsPageForParticipant).mockResolvedValue({
|
||||||
|
runs,
|
||||||
|
hasMore: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {queryByText, queryByTestId} = renderWithEverything(<ParticipantPlaybooks {...props}/>, {database, serverUrl});
|
||||||
|
|
||||||
|
await waitForElementToBeRemoved(() => queryByTestId('loading'));
|
||||||
|
|
||||||
|
expect(queryByText(/Showing cached data only/)).not.toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,242 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import {FlashList, type ListRenderItem} from '@shopify/flash-list';
|
||||||
|
import React, {useCallback, useEffect, useMemo, useState} from 'react';
|
||||||
|
import {defineMessages, useIntl} from 'react-intl';
|
||||||
|
import {View} from 'react-native';
|
||||||
|
|
||||||
|
import Loading from '@components/loading';
|
||||||
|
import SectionNotice from '@components/section_notice';
|
||||||
|
import {useServerUrl} from '@context/server';
|
||||||
|
import {useTheme} from '@context/theme';
|
||||||
|
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
|
||||||
|
import useTabs, {type TabDefinition} from '@hooks/use_tabs';
|
||||||
|
import Tabs from '@hooks/use_tabs/tabs';
|
||||||
|
import {fetchPlaybookRunsPageForParticipant} from '@playbooks/actions/remote/runs';
|
||||||
|
import PlaybookScreens from '@playbooks/constants/screens';
|
||||||
|
import {isRunFinished} from '@playbooks/utils/run';
|
||||||
|
import {popTopScreen} from '@screens/navigation';
|
||||||
|
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||||
|
|
||||||
|
import EmptyState from '../playbooks_runs/empty_state';
|
||||||
|
import PlaybookCard, {CARD_HEIGHT} from '../playbooks_runs/playbook_card';
|
||||||
|
|
||||||
|
import type PlaybookRunModel from '@playbooks/types/database/models/playbook_run';
|
||||||
|
import type {AvailableScreens} from '@typings/screens/navigation';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
currentUserId: string;
|
||||||
|
componentId: AvailableScreens;
|
||||||
|
cachedPlaybookRuns: PlaybookRunModel[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type TabsNames = 'in-progress' | 'finished';
|
||||||
|
|
||||||
|
const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => ({
|
||||||
|
container: {
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
|
tabContainer: {
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: changeOpacity(theme.centerChannelColor, 0.12),
|
||||||
|
},
|
||||||
|
warningText: {
|
||||||
|
color: theme.centerChannelColor,
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
warningContainer: {
|
||||||
|
paddingTop: 8,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
cachedWarningTitle: {
|
||||||
|
id: 'playbooks.participant_playbooks.cached_warning_title',
|
||||||
|
defaultMessage: 'Cannot reach the server',
|
||||||
|
},
|
||||||
|
cachedWarningMessage: {
|
||||||
|
id: 'playbooks.participant_playbooks.cached_warning_message',
|
||||||
|
defaultMessage: 'Showing cached data only. Some playbook runs or updates may be missing from this list.',
|
||||||
|
},
|
||||||
|
tabInProgress: {
|
||||||
|
id: 'playbooks.participant_playbooks.tab_in_progress',
|
||||||
|
defaultMessage: 'In Progress',
|
||||||
|
},
|
||||||
|
tabFinished: {
|
||||||
|
id: 'playbooks.participant_playbooks.tab_finished',
|
||||||
|
defaultMessage: 'Finished',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const tabs: Array<TabDefinition<TabsNames>> = [
|
||||||
|
{
|
||||||
|
id: 'in-progress',
|
||||||
|
name: messages.tabInProgress,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'finished',
|
||||||
|
name: messages.tabFinished,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const itemSeparatorStyle = {
|
||||||
|
height: 12,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ItemSeparator = () => {
|
||||||
|
return <View style={itemSeparatorStyle}/>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ParticipantPlaybooks = ({
|
||||||
|
currentUserId,
|
||||||
|
componentId,
|
||||||
|
cachedPlaybookRuns,
|
||||||
|
}: Props) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const serverUrl = useServerUrl();
|
||||||
|
const theme = useTheme();
|
||||||
|
const styles = getStyleFromTheme(theme);
|
||||||
|
|
||||||
|
const [participantRuns, setParticipantRuns] = useState<Array<PlaybookRun | PlaybookRunModel>>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
const [hasError, setHasError] = useState(false);
|
||||||
|
const [hasMore, setHasMore] = useState(false);
|
||||||
|
const [currentPage, setCurrentPage] = useState(0);
|
||||||
|
const [showCachedWarning, setShowCachedWarning] = useState(false);
|
||||||
|
|
||||||
|
const close = useCallback(() => {
|
||||||
|
popTopScreen(componentId);
|
||||||
|
}, [componentId]);
|
||||||
|
|
||||||
|
useAndroidHardwareBackHandler(componentId, close);
|
||||||
|
|
||||||
|
const fetchData = useCallback(async (page = 0, append = false) => {
|
||||||
|
if (!currentUserId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (append) {
|
||||||
|
setLoadingMore(true);
|
||||||
|
} else {
|
||||||
|
setLoading(true);
|
||||||
|
setHasError(false);
|
||||||
|
setShowCachedWarning(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await fetchPlaybookRunsPageForParticipant(serverUrl, currentUserId, page);
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
// Fallback to database cache only for the first page
|
||||||
|
if (page === 0 && cachedPlaybookRuns.length > 0) {
|
||||||
|
setParticipantRuns(cachedPlaybookRuns);
|
||||||
|
setHasMore(false);
|
||||||
|
setShowCachedWarning(true);
|
||||||
|
} else {
|
||||||
|
setHasError(true);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const newRuns = result.runs || [];
|
||||||
|
if (append) {
|
||||||
|
setParticipantRuns((prev) => [...prev, ...newRuns]);
|
||||||
|
} else {
|
||||||
|
setParticipantRuns(newRuns);
|
||||||
|
}
|
||||||
|
setHasMore(result.hasMore || false);
|
||||||
|
setCurrentPage(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (append) {
|
||||||
|
setLoadingMore(false);
|
||||||
|
} else {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [currentUserId, serverUrl, cachedPlaybookRuns]);
|
||||||
|
|
||||||
|
const loadMore = useCallback(() => {
|
||||||
|
if (!loadingMore && hasMore) {
|
||||||
|
fetchData(currentPage + 1, true);
|
||||||
|
}
|
||||||
|
}, [loadingMore, hasMore, currentPage, fetchData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
|
||||||
|
// Only fetch the data on mount
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [inProgressRuns, finishedRuns] = useMemo(() => {
|
||||||
|
const inProgress: Array<PlaybookRun | PlaybookRunModel> = [];
|
||||||
|
const finished: Array<PlaybookRun | PlaybookRunModel> = [];
|
||||||
|
|
||||||
|
participantRuns.forEach((run) => {
|
||||||
|
if (isRunFinished(run)) {
|
||||||
|
finished.push(run);
|
||||||
|
} else {
|
||||||
|
inProgress.push(run);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return [inProgress, finished] as const;
|
||||||
|
}, [participantRuns]);
|
||||||
|
|
||||||
|
const [activeTab, tabsProps] = useTabs<TabsNames>('in-progress', tabs);
|
||||||
|
|
||||||
|
const data = activeTab === 'in-progress' ? inProgressRuns : finishedRuns;
|
||||||
|
const isEmpty = data.length === 0;
|
||||||
|
|
||||||
|
const renderItem: ListRenderItem<PlaybookRun> = useCallback(({item}) => {
|
||||||
|
return (
|
||||||
|
<PlaybookCard
|
||||||
|
run={item}
|
||||||
|
location={PlaybookScreens.PARTICIPANT_PLAYBOOKS}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
let content;
|
||||||
|
if (loading) {
|
||||||
|
content = <Loading testID='loading'/>;
|
||||||
|
} else if (hasError || isEmpty) {
|
||||||
|
content = (<EmptyState tab={activeTab}/>);
|
||||||
|
} else {
|
||||||
|
content = (
|
||||||
|
<FlashList
|
||||||
|
data={data}
|
||||||
|
renderItem={renderItem}
|
||||||
|
contentContainerStyle={styles.container}
|
||||||
|
ItemSeparatorComponent={ItemSeparator}
|
||||||
|
estimatedItemSize={CARD_HEIGHT}
|
||||||
|
onEndReached={loadMore}
|
||||||
|
onEndReachedThreshold={0.1}
|
||||||
|
ListFooterComponent={loadingMore ? <Loading testID='loading.more'/> : undefined}
|
||||||
|
testID={'runs.list'}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<View style={styles.tabContainer}>
|
||||||
|
<Tabs {...tabsProps}/>
|
||||||
|
</View>
|
||||||
|
{showCachedWarning && (
|
||||||
|
<View style={styles.warningContainer}>
|
||||||
|
<SectionNotice
|
||||||
|
title={intl.formatMessage(messages.cachedWarningTitle)}
|
||||||
|
location={PlaybookScreens.PARTICIPANT_PLAYBOOKS}
|
||||||
|
text={intl.formatMessage(messages.cachedWarningMessage)}
|
||||||
|
type='warning'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{content}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ParticipantPlaybooks;
|
||||||
|
|
@ -337,6 +337,8 @@ export default function PlaybookRun({
|
||||||
<StatusUpdateIndicator
|
<StatusUpdateIndicator
|
||||||
isFinished={isFinished}
|
isFinished={isFinished}
|
||||||
timestamp={getRunScheduledTimestamp(playbookRun)}
|
timestamp={getRunScheduledTimestamp(playbookRun)}
|
||||||
|
isParticipant={isParticipant}
|
||||||
|
playbookRunId={playbookRun.id}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.tasksContainer}>
|
<View style={styles.tasksContainer}>
|
||||||
|
|
|
||||||
|
|
@ -32,10 +32,12 @@ describe('StatusUpdateIndicator', () => {
|
||||||
<StatusUpdateIndicator
|
<StatusUpdateIndicator
|
||||||
isFinished={false}
|
isFinished={false}
|
||||||
timestamp={futureTimestamp}
|
timestamp={futureTimestamp}
|
||||||
|
isParticipant={true}
|
||||||
|
playbookRunId='run-id'
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const text = getByText(/Status update due/);
|
const text = getByText(/Update due/);
|
||||||
expect(text).toHaveStyle({color: changeOpacity(Preferences.THEMES.denim.centerChannelColor, 0.72)});
|
expect(text).toHaveStyle({color: changeOpacity(Preferences.THEMES.denim.centerChannelColor, 0.72)});
|
||||||
|
|
||||||
const icon = getByTestId('compass-icon');
|
const icon = getByTestId('compass-icon');
|
||||||
|
|
@ -48,10 +50,12 @@ describe('StatusUpdateIndicator', () => {
|
||||||
<StatusUpdateIndicator
|
<StatusUpdateIndicator
|
||||||
isFinished={false}
|
isFinished={false}
|
||||||
timestamp={pastTimestamp}
|
timestamp={pastTimestamp}
|
||||||
|
isParticipant={true}
|
||||||
|
playbookRunId='run-id'
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const text = getByText(/Status update overdue/);
|
const text = getByText(/Update overdue/);
|
||||||
expect(text).toHaveStyle({color: Preferences.THEMES.denim.dndIndicator});
|
expect(text).toHaveStyle({color: Preferences.THEMES.denim.dndIndicator});
|
||||||
|
|
||||||
const icon = getByTestId('compass-icon');
|
const icon = getByTestId('compass-icon');
|
||||||
|
|
@ -64,6 +68,8 @@ describe('StatusUpdateIndicator', () => {
|
||||||
<StatusUpdateIndicator
|
<StatusUpdateIndicator
|
||||||
isFinished={true}
|
isFinished={true}
|
||||||
timestamp={pastTimestamp}
|
timestamp={pastTimestamp}
|
||||||
|
isParticipant={true}
|
||||||
|
playbookRunId='run-id'
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,24 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import React, {useMemo} from 'react';
|
import React, {useCallback, useMemo} from 'react';
|
||||||
import {defineMessages, useIntl} from 'react-intl';
|
import {defineMessages, useIntl} from 'react-intl';
|
||||||
import {View, Text} from 'react-native';
|
import {View, Text} from 'react-native';
|
||||||
|
|
||||||
|
import Button from '@components/button';
|
||||||
import CompassIcon from '@components/compass_icon';
|
import CompassIcon from '@components/compass_icon';
|
||||||
import FriendlyDate from '@components/friendly_date';
|
import FriendlyDate from '@components/friendly_date';
|
||||||
import {useTheme} from '@context/theme';
|
import {useTheme} from '@context/theme';
|
||||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||||
import {typography} from '@utils/typography';
|
import {typography} from '@utils/typography';
|
||||||
|
|
||||||
|
import {goToPostUpdate} from '../navigation';
|
||||||
|
|
||||||
type StatusUpdateIndicatorProps = {
|
type StatusUpdateIndicatorProps = {
|
||||||
isFinished: boolean;
|
isFinished: boolean;
|
||||||
|
isParticipant: boolean;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
|
playbookRunId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getStyleSheet = makeStyleSheetFromTheme((theme) => ({
|
const getStyleSheet = makeStyleSheetFromTheme((theme) => ({
|
||||||
|
|
@ -27,6 +32,15 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => ({
|
||||||
borderColor: changeOpacity(theme.centerChannelColor, 0.16),
|
borderColor: changeOpacity(theme.centerChannelColor, 0.16),
|
||||||
gap: 8,
|
gap: 8,
|
||||||
},
|
},
|
||||||
|
due: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
dueTextContainer: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
icon: {
|
icon: {
|
||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
color: changeOpacity(theme.centerChannelColor, 0.72),
|
color: changeOpacity(theme.centerChannelColor, 0.72),
|
||||||
|
|
@ -41,37 +55,54 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => ({
|
||||||
overdueText: {
|
overdueText: {
|
||||||
color: theme.dndIndicator,
|
color: theme.dndIndicator,
|
||||||
},
|
},
|
||||||
|
bold: {
|
||||||
|
...typography('Body', 300, 'SemiBold'),
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
updateOverdue: {
|
updateOverdue: {
|
||||||
id: 'playbooks.playbook_run.status_update_overdue',
|
id: 'playbooks.playbook_run.status_update_overdue',
|
||||||
defaultMessage: 'Status update overdue {time}',
|
defaultMessage: 'Update overdue\n{time}',
|
||||||
},
|
},
|
||||||
updateDue: {
|
updateDue: {
|
||||||
id: 'playbooks.playbook_run.status_update_due',
|
id: 'playbooks.playbook_run.status_update_due',
|
||||||
defaultMessage: 'Status update due {time}',
|
defaultMessage: 'Update due\n{time}',
|
||||||
},
|
},
|
||||||
finished: {
|
finished: {
|
||||||
id: 'playbooks.playbook_run.status_update_finished',
|
id: 'playbooks.playbook_run.status_update_finished',
|
||||||
defaultMessage: 'Run finished {time}',
|
defaultMessage: 'Run finished\n{time}',
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
id: 'playbooks.playbook_run.status_update',
|
||||||
|
defaultMessage: 'Post update',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const StatusUpdateIndicator = ({
|
const StatusUpdateIndicator = ({
|
||||||
isFinished,
|
isFinished,
|
||||||
timestamp,
|
timestamp,
|
||||||
|
isParticipant,
|
||||||
|
playbookRunId,
|
||||||
}: StatusUpdateIndicatorProps) => {
|
}: StatusUpdateIndicatorProps) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const styles = getStyleSheet(theme);
|
const styles = getStyleSheet(theme);
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
const values = {time: <FriendlyDate value={timestamp}/>};
|
const values = useMemo(() => ({time: (
|
||||||
|
<FriendlyDate
|
||||||
|
style={styles.bold}
|
||||||
|
value={timestamp}
|
||||||
|
/>
|
||||||
|
)}), [timestamp, styles]);
|
||||||
|
|
||||||
|
const readOnly = !isParticipant || isFinished;
|
||||||
|
const isOverdue = timestamp < Date.now();
|
||||||
|
|
||||||
let message = messages.updateDue;
|
let message = messages.updateDue;
|
||||||
if (isFinished) {
|
if (isFinished) {
|
||||||
message = messages.finished;
|
message = messages.finished;
|
||||||
} else if (timestamp < Date.now()) {
|
} else if (isOverdue) {
|
||||||
message = messages.updateOverdue;
|
message = messages.updateOverdue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -88,16 +119,36 @@ const StatusUpdateIndicator = ({
|
||||||
];
|
];
|
||||||
}, [styles.icon, styles.overdueText, isFinished, timestamp]);
|
}, [styles.icon, styles.overdueText, isFinished, timestamp]);
|
||||||
|
|
||||||
|
const onUpdatePress = useCallback(async () => {
|
||||||
|
if (readOnly) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await goToPostUpdate(intl, playbookRunId);
|
||||||
|
}, [intl, playbookRunId, readOnly]);
|
||||||
|
|
||||||
const icon = isFinished ? 'flag-checkered' : 'clock-outline';
|
const icon = isFinished ? 'flag-checkered' : 'clock-outline';
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<CompassIcon
|
<View style={styles.due}>
|
||||||
name={icon}
|
<CompassIcon
|
||||||
style={iconStyle}
|
name={icon}
|
||||||
|
style={iconStyle}
|
||||||
|
/>
|
||||||
|
<View style={styles.dueTextContainer}>
|
||||||
|
<Text style={textStyle}>
|
||||||
|
{intl.formatMessage(message, values)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Button
|
||||||
|
text={intl.formatMessage(messages.update)}
|
||||||
|
onPress={onUpdatePress}
|
||||||
|
theme={theme}
|
||||||
|
size='lg'
|
||||||
|
disabled={readOnly}
|
||||||
|
isDestructive={isOverdue && !isFinished}
|
||||||
/>
|
/>
|
||||||
<Text style={textStyle}>
|
|
||||||
{intl.formatMessage(message, values)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import React, {type ComponentProps} from 'react';
|
||||||
import UserChip from '@components/chips/user_chip';
|
import UserChip from '@components/chips/user_chip';
|
||||||
import UserAvatarsStack from '@components/user_avatars_stack';
|
import UserAvatarsStack from '@components/user_avatars_stack';
|
||||||
import ProgressBar from '@playbooks/components/progress_bar';
|
import ProgressBar from '@playbooks/components/progress_bar';
|
||||||
import {goToPlaybookRun} from '@playbooks/screens/navigation';
|
import {goToPlaybookRun, goToPlaybookRunWithChannelSwitch} from '@playbooks/screens/navigation';
|
||||||
import {openUserProfileModal} from '@screens/navigation';
|
import {openUserProfileModal} from '@screens/navigation';
|
||||||
import {renderWithIntl} from '@test/intl-test-helper';
|
import {renderWithIntl} from '@test/intl-test-helper';
|
||||||
import TestHelper from '@test/test_helper';
|
import TestHelper from '@test/test_helper';
|
||||||
|
|
@ -88,7 +88,7 @@ describe('PlaybookCard', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('navigates to playbook run on press', () => {
|
it('navigates to playbook run on press for regular location', () => {
|
||||||
const props = getBaseProps();
|
const props = getBaseProps();
|
||||||
const {getByText} = renderWithIntl(<PlaybookCard {...props}/>);
|
const {getByText} = renderWithIntl(<PlaybookCard {...props}/>);
|
||||||
|
|
||||||
|
|
@ -101,6 +101,24 @@ describe('PlaybookCard', () => {
|
||||||
props.run.id,
|
props.run.id,
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
|
expect(goToPlaybookRunWithChannelSwitch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('navigates to playbook run with channel switch when location is PARTICIPANT_PLAYBOOKS', () => {
|
||||||
|
const props = getBaseProps();
|
||||||
|
props.location = 'ParticipantPlaybooks';
|
||||||
|
const {getByText} = renderWithIntl(<PlaybookCard {...props}/>);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
fireEvent.press(getByText('Test Playbook Run'));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(goToPlaybookRunWithChannelSwitch).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything(), // serverUrl
|
||||||
|
props.run,
|
||||||
|
);
|
||||||
|
expect(goToPlaybookRun).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows finished state when run is complete', () => {
|
it('shows finished state when run is complete', () => {
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,11 @@ import {CHIP_HEIGHT} from '@components/chips/constants';
|
||||||
import UserChip from '@components/chips/user_chip';
|
import UserChip from '@components/chips/user_chip';
|
||||||
import FriendlyDate from '@components/friendly_date';
|
import FriendlyDate from '@components/friendly_date';
|
||||||
import UserAvatarsStack from '@components/user_avatars_stack';
|
import UserAvatarsStack from '@components/user_avatars_stack';
|
||||||
|
import {useServerUrl} from '@context/server';
|
||||||
import {useTheme} from '@context/theme';
|
import {useTheme} from '@context/theme';
|
||||||
import ProgressBar from '@playbooks/components/progress_bar';
|
import ProgressBar from '@playbooks/components/progress_bar';
|
||||||
import {goToPlaybookRun} from '@playbooks/screens/navigation';
|
import PlaybookScreens from '@playbooks/constants/screens';
|
||||||
|
import {goToPlaybookRun, goToPlaybookRunWithChannelSwitch} from '@playbooks/screens/navigation';
|
||||||
import {isRunFinished} from '@playbooks/utils/run';
|
import {isRunFinished} from '@playbooks/utils/run';
|
||||||
import {openUserProfileModal} from '@screens/navigation';
|
import {openUserProfileModal} from '@screens/navigation';
|
||||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||||
|
|
@ -89,13 +91,19 @@ const PlaybookCard = ({
|
||||||
const lastUpdateAt = 'updateAt' in run ? run.updateAt : run.update_at;
|
const lastUpdateAt = 'updateAt' in run ? run.updateAt : run.update_at;
|
||||||
|
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
const serverUrl = useServerUrl();
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const styles = getStyleFromTheme(theme);
|
const styles = getStyleFromTheme(theme);
|
||||||
const finished = isRunFinished(run);
|
const finished = isRunFinished(run);
|
||||||
|
|
||||||
const onCardPress = useCallback(() => {
|
const onCardPress = useCallback(() => {
|
||||||
goToPlaybookRun(intl, run.id, 'observe' in run ? undefined : run);
|
// If we're coming from the participant playbooks screen, we need to switch to the channel first
|
||||||
}, [intl, run]);
|
if (location === PlaybookScreens.PARTICIPANT_PLAYBOOKS) {
|
||||||
|
goToPlaybookRunWithChannelSwitch(intl, serverUrl, run as PlaybookRun);
|
||||||
|
} else {
|
||||||
|
goToPlaybookRun(intl, run.id, 'observe' in run ? undefined : run);
|
||||||
|
}
|
||||||
|
}, [intl, serverUrl, run, location]);
|
||||||
|
|
||||||
const onUserChipPress = useCallback((userId: string) => {
|
const onUserChipPress = useCallback((userId: string) => {
|
||||||
openUserProfileModal(intl, theme, {
|
openUserProfileModal(intl, theme, {
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,10 @@
|
||||||
|
|
||||||
import {FlashList, type ListRenderItem} from '@shopify/flash-list';
|
import {FlashList, type ListRenderItem} from '@shopify/flash-list';
|
||||||
import React, {useCallback, useMemo, useState} from 'react';
|
import React, {useCallback, useMemo, useState} from 'react';
|
||||||
import {defineMessage} from 'react-intl';
|
import {defineMessage, useIntl} from 'react-intl';
|
||||||
import {StyleSheet, View} from 'react-native';
|
import {StyleSheet, View} from 'react-native';
|
||||||
|
|
||||||
|
import Button from '@components/button';
|
||||||
import {Screens} from '@constants';
|
import {Screens} from '@constants';
|
||||||
import {useTheme} from '@context/theme';
|
import {useTheme} from '@context/theme';
|
||||||
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
|
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
|
||||||
|
|
@ -15,6 +16,8 @@ import {isRunFinished} from '@playbooks/utils/run';
|
||||||
import {popTopScreen} from '@screens/navigation';
|
import {popTopScreen} from '@screens/navigation';
|
||||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||||
|
|
||||||
|
import {goToSelectPlaybook} from '../navigation';
|
||||||
|
|
||||||
import EmptyState from './empty_state';
|
import EmptyState from './empty_state';
|
||||||
import PlaybookCard, {CARD_HEIGHT} from './playbook_card';
|
import PlaybookCard, {CARD_HEIGHT} from './playbook_card';
|
||||||
import ShowMoreButton from './show_more_button';
|
import ShowMoreButton from './show_more_button';
|
||||||
|
|
@ -43,6 +46,9 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => ({
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: changeOpacity(theme.centerChannelColor, 0.12),
|
borderBottomColor: changeOpacity(theme.centerChannelColor, 0.12),
|
||||||
},
|
},
|
||||||
|
startANewRunButtonContainer: {
|
||||||
|
padding: 20,
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const ItemSeparator = () => {
|
const ItemSeparator = () => {
|
||||||
|
|
@ -71,6 +77,7 @@ const PlaybookRuns = ({
|
||||||
allRuns,
|
allRuns,
|
||||||
componentId,
|
componentId,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
|
const intl = useIntl();
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const styles = getStyleFromTheme(theme);
|
const styles = getStyleFromTheme(theme);
|
||||||
|
|
||||||
|
|
@ -132,17 +139,33 @@ const PlaybookRuns = ({
|
||||||
);
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const startANewRun = useCallback(() => {
|
||||||
|
goToSelectPlaybook(intl, theme, channelId);
|
||||||
|
}, [intl, theme, channelId]);
|
||||||
|
|
||||||
let content = (<EmptyState tab={activeTab}/>);
|
let content = (<EmptyState tab={activeTab}/>);
|
||||||
if (!isEmpty) {
|
if (!isEmpty) {
|
||||||
content = (
|
content = (
|
||||||
<FlashList
|
<>
|
||||||
data={data}
|
<FlashList
|
||||||
renderItem={renderItem}
|
data={data}
|
||||||
contentContainerStyle={styles.container}
|
renderItem={renderItem}
|
||||||
ItemSeparatorComponent={ItemSeparator}
|
contentContainerStyle={styles.container}
|
||||||
estimatedItemSize={CARD_HEIGHT}
|
ItemSeparatorComponent={ItemSeparator}
|
||||||
ListFooterComponent={footerComponent}
|
estimatedItemSize={CARD_HEIGHT}
|
||||||
/>
|
ListFooterComponent={footerComponent}
|
||||||
|
/>
|
||||||
|
<View style={styles.startANewRunButtonContainer}>
|
||||||
|
<Button
|
||||||
|
emphasis='tertiary'
|
||||||
|
onPress={startANewRun}
|
||||||
|
text={intl.formatMessage({id: 'playbooks.runs.start_a_new_run', defaultMessage: 'Start a new run'})}
|
||||||
|
size='lg'
|
||||||
|
theme={theme}
|
||||||
|
iconName='play-outline'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
56
app/products/playbooks/screens/post_update/index.ts
Normal file
56
app/products/playbooks/screens/post_update/index.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
// 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 {queryPlaybookChecklistByRun} from '@playbooks/database/queries/checklist';
|
||||||
|
import {queryPlaybookChecklistItemsByChecklists} from '@playbooks/database/queries/item';
|
||||||
|
import {observePlaybookRunById} from '@playbooks/database/queries/run';
|
||||||
|
import {isOutstanding} from '@playbooks/utils/run';
|
||||||
|
import {observeCurrentTeamId, observeCurrentUserId} from '@queries/servers/system';
|
||||||
|
|
||||||
|
import PostUpdate from './post_update';
|
||||||
|
|
||||||
|
import type PlaybookChecklistModel from '@playbooks/types/database/models/playbook_checklist';
|
||||||
|
import type {WithDatabaseArgs} from '@typings/database/database';
|
||||||
|
|
||||||
|
type OwnProps = {
|
||||||
|
playbookRunId: string;
|
||||||
|
playbookRun?: PlaybookRun;
|
||||||
|
} & WithDatabaseArgs;
|
||||||
|
|
||||||
|
const getIds = (checklists: PlaybookChecklistModel[]) => {
|
||||||
|
return checklists.map((c) => c.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const enhanced = withObservables(['playbookRunId'], ({playbookRunId, database}: OwnProps) => {
|
||||||
|
const playbookRun = observePlaybookRunById(database, playbookRunId);
|
||||||
|
|
||||||
|
const checklists = queryPlaybookChecklistByRun(database, playbookRunId).observe();
|
||||||
|
const outstandingCount = checklists.pipe(
|
||||||
|
switchMap((cs) => {
|
||||||
|
const ids = getIds(cs);
|
||||||
|
return queryPlaybookChecklistItemsByChecklists(database, ids).observeWithColumns(['state']);
|
||||||
|
}),
|
||||||
|
switchMap((items) => {
|
||||||
|
const overdue = items.filter(isOutstanding).length;
|
||||||
|
return of$(overdue);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
runName: playbookRun.pipe(
|
||||||
|
switchMap((r) => of$(r?.name ?? '')),
|
||||||
|
),
|
||||||
|
userId: observeCurrentUserId(database),
|
||||||
|
channelId: playbookRun.pipe(
|
||||||
|
switchMap((r) => (r ? of$(r.channelId) : of$(undefined))),
|
||||||
|
),
|
||||||
|
teamId: observeCurrentTeamId(database),
|
||||||
|
outstanding: outstandingCount,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export default withDatabase(enhanced(PostUpdate));
|
||||||
317
app/products/playbooks/screens/post_update/post_update.tsx
Normal file
317
app/products/playbooks/screens/post_update/post_update.tsx
Normal file
|
|
@ -0,0 +1,317 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import {type ComponentProps, useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||||
|
import {FormattedMessage, useIntl} from 'react-intl';
|
||||||
|
import {Alert, Keyboard, Text} from 'react-native';
|
||||||
|
import {KeyboardAwareScrollView} from 'react-native-keyboard-aware-scroll-view';
|
||||||
|
|
||||||
|
import {getPosts} from '@actions/local/post';
|
||||||
|
import FloatingAutocompleteSelector from '@components/floating_input/floating_autocomplete_selector';
|
||||||
|
import FloatingTextInput from '@components/floating_input/floating_text_input_label';
|
||||||
|
import Loading from '@components/loading';
|
||||||
|
import OptionItem from '@components/option_item';
|
||||||
|
import {useServerUrl} from '@context/server';
|
||||||
|
import {useTheme} from '@context/theme';
|
||||||
|
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
|
||||||
|
import {useAvoidKeyboard} from '@hooks/device';
|
||||||
|
import useNavButtonPressed from '@hooks/navigation_button_pressed';
|
||||||
|
import SecurityManager from '@managers/security_manager';
|
||||||
|
import {fetchPlaybookRun, fetchPlaybookRunMetadata, postStatusUpdate} from '@playbooks/actions/remote/runs';
|
||||||
|
import {buildNavigationButton, popTopScreen, setButtons} from '@screens/navigation';
|
||||||
|
import {toSeconds} from '@utils/datetime';
|
||||||
|
import {logDebug} from '@utils/log';
|
||||||
|
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||||
|
import {typography} from '@utils/typography';
|
||||||
|
|
||||||
|
import type {AvailableScreens} from '@typings/screens/navigation';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
componentId: AvailableScreens;
|
||||||
|
playbookRunId: string;
|
||||||
|
runName: string;
|
||||||
|
userId: string;
|
||||||
|
channelId?: string;
|
||||||
|
teamId: string;
|
||||||
|
outstanding: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStyles = makeStyleSheetFromTheme((theme: Theme) => ({
|
||||||
|
container: {
|
||||||
|
padding: 20,
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
introMessage: {
|
||||||
|
...typography('Body', 200),
|
||||||
|
color: changeOpacity(theme.centerChannelColor, 0.72),
|
||||||
|
},
|
||||||
|
introMessageBold: {
|
||||||
|
...typography('Body', 200, 'SemiBold'),
|
||||||
|
},
|
||||||
|
flex: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const SAVE_BUTTON_ID = 'save-post-update';
|
||||||
|
|
||||||
|
const close = (componentId: AvailableScreens): void => {
|
||||||
|
Keyboard.dismiss();
|
||||||
|
popTopScreen(componentId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const valueToTimeMap = {
|
||||||
|
'15_minutes': toSeconds({minutes: 15}),
|
||||||
|
'30_minutes': toSeconds({minutes: 30}),
|
||||||
|
'1_hour': toSeconds({hours: 1}),
|
||||||
|
'4_hours': toSeconds({hours: 4}),
|
||||||
|
'1_day': toSeconds({days: 1}),
|
||||||
|
'7_days': toSeconds({days: 7}),
|
||||||
|
};
|
||||||
|
|
||||||
|
type NextUpdateValues = keyof typeof valueToTimeMap;
|
||||||
|
|
||||||
|
const PostUpdate = ({
|
||||||
|
componentId,
|
||||||
|
playbookRunId,
|
||||||
|
runName,
|
||||||
|
userId,
|
||||||
|
channelId,
|
||||||
|
teamId,
|
||||||
|
outstanding,
|
||||||
|
}: Props) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const intl = useIntl();
|
||||||
|
const serverUrl = useServerUrl();
|
||||||
|
const styles = getStyles(theme);
|
||||||
|
const [updateMessage, setUpdateMessage] = useState('');
|
||||||
|
const [nextUpdate, setNextUpdate] = useState<NextUpdateValues>('15_minutes');
|
||||||
|
const [alsoMarkRunAsFinished, setAlsoMarkRunAsFinished] = useState(false);
|
||||||
|
const [canSave, setCanSave] = useState(false);
|
||||||
|
|
||||||
|
const keyboardAwareRef = useRef<KeyboardAwareScrollView>(null);
|
||||||
|
useAvoidKeyboard(keyboardAwareRef);
|
||||||
|
|
||||||
|
const [followersCount, setFollowersCount] = useState<number>(0);
|
||||||
|
const [broadcastChannelCount, setBroadcastChannelCount] = useState<number>(0);
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const onChangeText = useCallback((text: string) => {
|
||||||
|
setUpdateMessage(text);
|
||||||
|
setCanSave(text.trim().length > 0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const rightButton = useMemo(() => {
|
||||||
|
const base = buildNavigationButton(
|
||||||
|
SAVE_BUTTON_ID,
|
||||||
|
'playbooks.edit_command.save.button',
|
||||||
|
undefined,
|
||||||
|
intl.formatMessage({id: 'playbooks.post_update.post.button', defaultMessage: 'Post'}),
|
||||||
|
);
|
||||||
|
base.enabled = canSave;
|
||||||
|
base.color = theme.sidebarHeaderTextColor;
|
||||||
|
return base;
|
||||||
|
}, [intl, canSave, theme.sidebarHeaderTextColor]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setButtons(componentId, {
|
||||||
|
rightButtons: [rightButton],
|
||||||
|
});
|
||||||
|
}, [rightButton, componentId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function initialLoad() {
|
||||||
|
let calculatedFollowersCount = 0;
|
||||||
|
let calculatedBroadcastChannelCount = 0;
|
||||||
|
let calculatedDefaultMessage = '';
|
||||||
|
|
||||||
|
const metadataRes = await fetchPlaybookRunMetadata(serverUrl, playbookRunId);
|
||||||
|
if (metadataRes.error) {
|
||||||
|
logDebug('error on fetchPlaybookRunMetadata', metadataRes.error);
|
||||||
|
} else {
|
||||||
|
calculatedFollowersCount = metadataRes.metadata?.followers?.length ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const runRes = await fetchPlaybookRun(serverUrl, playbookRunId, true);
|
||||||
|
if (runRes.error) {
|
||||||
|
logDebug('error on fetchPlaybookRun', runRes.error);
|
||||||
|
} else {
|
||||||
|
if (runRes.run?.status_update_broadcast_channels_enabled) {
|
||||||
|
calculatedBroadcastChannelCount = runRes.run?.broadcast_channel_ids?.length ?? 0;
|
||||||
|
}
|
||||||
|
calculatedDefaultMessage = runRes.run?.reminder_message_template ?? '';
|
||||||
|
const lastStatusPostMetadata = runRes.run?.status_posts?.slice().reverse().find((post) => !post.delete_at);
|
||||||
|
if (lastStatusPostMetadata?.id) {
|
||||||
|
const lastStatusPost = (await getPosts(serverUrl, [lastStatusPostMetadata?.id ?? '']))[0];
|
||||||
|
if (lastStatusPost) {
|
||||||
|
calculatedDefaultMessage = lastStatusPost.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setFollowersCount(calculatedFollowersCount);
|
||||||
|
setBroadcastChannelCount(calculatedBroadcastChannelCount);
|
||||||
|
setUpdateMessage(calculatedDefaultMessage);
|
||||||
|
setCanSave(calculatedDefaultMessage.trim().length > 0);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
initialLoad();
|
||||||
|
|
||||||
|
// This is the initial load, so we don't need to re-run it on every change
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onNextUpdateSelected = useCallback((value: SelectedDialogOption) => {
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
// Multiselect, which should never happen
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setNextUpdate(value.value as NextUpdateValues);
|
||||||
|
}, [setNextUpdate]);
|
||||||
|
|
||||||
|
const dialogOptions = useMemo<DialogOption[]>(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
text: intl.formatMessage({id: 'playbooks.post_update.option.15_minutes', defaultMessage: '15 minutes'}),
|
||||||
|
value: '15_minutes',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: intl.formatMessage({id: 'playbooks.post_update.option.30_minutes', defaultMessage: '30 minutes'}),
|
||||||
|
value: '30_minutes',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: intl.formatMessage({id: 'playbooks.post_update.option.1_hour', defaultMessage: '1 hour'}),
|
||||||
|
value: '1_hour',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: intl.formatMessage({id: 'playbooks.post_update.option.4_hours', defaultMessage: '4 hours'}),
|
||||||
|
value: '4_hours',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: intl.formatMessage({id: 'playbooks.post_update.option.1_day', defaultMessage: '1 day'}),
|
||||||
|
value: '1_day',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: intl.formatMessage({id: 'playbooks.post_update.option.7_days', defaultMessage: '7 days'}),
|
||||||
|
value: '7_days',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [intl]);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
close(componentId);
|
||||||
|
}, [componentId]);
|
||||||
|
|
||||||
|
const onConfirm = useCallback(() => {
|
||||||
|
close(componentId);
|
||||||
|
if (!channelId) {
|
||||||
|
// This should never happen, but this keeps typescript happy
|
||||||
|
logDebug('cannot post status update without a channel id');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
postStatusUpdate(serverUrl, playbookRunId, {message: updateMessage, reminder: valueToTimeMap[nextUpdate], finishRun: alsoMarkRunAsFinished}, {user_id: userId, channel_id: channelId, team_id: teamId});
|
||||||
|
}, [alsoMarkRunAsFinished, channelId, componentId, nextUpdate, playbookRunId, serverUrl, teamId, updateMessage, userId]);
|
||||||
|
|
||||||
|
const onPostUpdate = useCallback(() => {
|
||||||
|
if (alsoMarkRunAsFinished) {
|
||||||
|
let message = intl.formatMessage({id: 'playbooks.post_update.confirm.message', defaultMessage: 'Are you sure you want to finish the run {runName} for all participants?'}, {runName});
|
||||||
|
if (outstanding > 0) {
|
||||||
|
message = intl.formatMessage({id: 'playbooks.post_update.confirm.message.with_tasks', defaultMessage: 'There {outstanding, plural, =1 {is # outstanding task} other {are # outstanding tasks}}. Are you sure you want to finish the run {runName} for all participants?'}, {runName, outstanding});
|
||||||
|
}
|
||||||
|
Alert.alert(
|
||||||
|
intl.formatMessage({id: 'playbooks.post_update.confirm.title', defaultMessage: 'Confirm finish run'}),
|
||||||
|
message,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: intl.formatMessage({id: 'playbooks.post_update.confirm.cancel', defaultMessage: 'Cancel'}),
|
||||||
|
style: 'cancel',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: intl.formatMessage({id: 'playbooks.post_update.confirm.confirm', defaultMessage: 'Finish run'}),
|
||||||
|
onPress: onConfirm,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
onConfirm();
|
||||||
|
}
|
||||||
|
}, [alsoMarkRunAsFinished, intl, onConfirm, outstanding, runName]);
|
||||||
|
|
||||||
|
useNavButtonPressed(SAVE_BUTTON_ID, componentId, onPostUpdate, [onPostUpdate]);
|
||||||
|
useAndroidHardwareBackHandler(componentId, handleClose);
|
||||||
|
|
||||||
|
const introMessageValues = useMemo<ComponentProps<typeof FormattedMessage>['values']>(() => {
|
||||||
|
return {
|
||||||
|
runName,
|
||||||
|
hasFollowersAndChannels: Boolean(followersCount && broadcastChannelCount).toString(),
|
||||||
|
hasChannels: Boolean(broadcastChannelCount).toString(),
|
||||||
|
hasFollowers: Boolean(followersCount).toString(),
|
||||||
|
broadcastChannelCount,
|
||||||
|
followersChannelCount: followersCount,
|
||||||
|
Bold: (...chunks) => <Text style={styles.introMessageBold}>{chunks}</Text>,
|
||||||
|
};
|
||||||
|
}, [runName, followersCount, broadcastChannelCount, styles.introMessageBold]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <Loading/>;
|
||||||
|
}
|
||||||
|
let introMessage;
|
||||||
|
if (broadcastChannelCount + followersCount === 0) {
|
||||||
|
introMessage = (
|
||||||
|
<FormattedMessage
|
||||||
|
id='playbooks.post_update.intro.no_followers_or_broadcast_channels'
|
||||||
|
defaultMessage='This update will be saved to the overview page.'
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
introMessage = (
|
||||||
|
<FormattedMessage
|
||||||
|
id='playbooks.post_update.intro'
|
||||||
|
defaultMessage='This update for the run <Bold>{runName}</Bold> will be broadcasted to {hasChannels, select, true {<Bold>{broadcastChannelCount, plural, =1 {one channel} other {{broadcastChannelCount, number} channels}}</Bold>} other {}}{hasFollowersAndChannels, select, true { and } other {}}{hasFollowers, select, true {<Bold>{followersChannelCount, plural, =1 {one direct message} other {{followersChannelCount, number} direct messages}}</Bold>} other {}}.'
|
||||||
|
values={introMessageValues}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardAwareScrollView
|
||||||
|
contentContainerStyle={styles.container}
|
||||||
|
ref={keyboardAwareRef}
|
||||||
|
nativeID={SecurityManager.getShieldScreenId(componentId, false, true)}
|
||||||
|
style={styles.flex}
|
||||||
|
>
|
||||||
|
<Text style={styles.introMessage}>{introMessage}</Text>
|
||||||
|
<FloatingTextInput
|
||||||
|
label={intl.formatMessage({id: 'playbooks.post_update.label', defaultMessage: 'Update message'})}
|
||||||
|
placeholder={intl.formatMessage({id: 'playbooks.post_update.placeholder', defaultMessage: 'Enter your update message'})}
|
||||||
|
value={updateMessage}
|
||||||
|
onChangeText={onChangeText}
|
||||||
|
theme={theme}
|
||||||
|
multiline={true}
|
||||||
|
multilineInputHeight={300}
|
||||||
|
/>
|
||||||
|
<FloatingAutocompleteSelector
|
||||||
|
options={dialogOptions}
|
||||||
|
testID='playbooks.post_update.selector'
|
||||||
|
selected={nextUpdate}
|
||||||
|
onSelected={onNextUpdateSelected}
|
||||||
|
label={intl.formatMessage({id: 'playbooks.post_update.label.next_update', defaultMessage: 'Timer for next update'})}
|
||||||
|
/>
|
||||||
|
<OptionItem
|
||||||
|
label={intl.formatMessage({id: 'playbooks.post_update.label.also_mark_run_as_finished', defaultMessage: 'Also mark the run as finished'})}
|
||||||
|
action={setAlsoMarkRunAsFinished}
|
||||||
|
testID='playbooks.post_update.selector.also_mark_run_as_finished'
|
||||||
|
selected={alsoMarkRunAsFinished}
|
||||||
|
type='toggle'
|
||||||
|
/>
|
||||||
|
</KeyboardAwareScrollView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PostUpdate;
|
||||||
|
|
@ -32,7 +32,7 @@ describe('SelectDate', () => {
|
||||||
function getBaseProps(): ComponentProps<typeof SelectDate> {
|
function getBaseProps(): ComponentProps<typeof SelectDate> {
|
||||||
const mockOnSave = jest.fn();
|
const mockOnSave = jest.fn();
|
||||||
|
|
||||||
const mockComponentId: AvailableScreens = 'SelectDate';
|
const mockComponentId: AvailableScreens = 'PlaybooksSelectDate';
|
||||||
const mockCurrentUserTimezone: UserTimezone = {
|
const mockCurrentUserTimezone: UserTimezone = {
|
||||||
automaticTimezone: 'UTC',
|
automaticTimezone: 'UTC',
|
||||||
manualTimezone: '',
|
manualTimezone: '',
|
||||||
|
|
|
||||||
35
app/products/playbooks/screens/select_playbook/index.ts
Normal file
35
app/products/playbooks/screens/select_playbook/index.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
// 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 {queryPlaybookRunsPerChannel} from '@playbooks/database/queries/run';
|
||||||
|
import {observeCurrentUserId, observeCurrentTeamId, observeCurrentChannelId} from '@queries/servers/system';
|
||||||
|
|
||||||
|
import SelectPlaybook from './select_playbook';
|
||||||
|
|
||||||
|
import type PlaybookRunModel from '@playbooks/types/database/models/playbook_run';
|
||||||
|
import type {WithDatabaseArgs} from '@typings/database/database';
|
||||||
|
|
||||||
|
function getPlaybookIdsFromRuns(runs: PlaybookRunModel[]) {
|
||||||
|
return runs.reduce((acc, run) => {
|
||||||
|
acc.add(run.playbookId);
|
||||||
|
return acc;
|
||||||
|
}, new Set<string>());
|
||||||
|
}
|
||||||
|
|
||||||
|
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
|
||||||
|
const playbooksUsedInChannel = observeCurrentChannelId(database).pipe(
|
||||||
|
switchMap((id) => (id ? queryPlaybookRunsPerChannel(database, id).observe() : of$([]))),
|
||||||
|
switchMap((runs) => of$(getPlaybookIdsFromRuns(runs))),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
currentUserId: observeCurrentUserId(database),
|
||||||
|
currentTeamId: observeCurrentTeamId(database),
|
||||||
|
playbooksUsedInChannel,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export default withDatabase(enhanced(SelectPlaybook));
|
||||||
116
app/products/playbooks/screens/select_playbook/playbook_row.tsx
Normal file
116
app/products/playbooks/screens/select_playbook/playbook_row.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {useIntl} from 'react-intl';
|
||||||
|
import {View, Text, TouchableOpacity} from 'react-native';
|
||||||
|
|
||||||
|
import CompassIcon from '@components/compass_icon';
|
||||||
|
import {getFriendlyDate} from '@components/friendly_date';
|
||||||
|
import {useTheme} from '@context/theme';
|
||||||
|
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||||
|
import {typography} from '@utils/typography';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
playbook: Playbook;
|
||||||
|
onPress?: (playbook: Playbook) => void;
|
||||||
|
testID?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||||
|
return {
|
||||||
|
container: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: 16,
|
||||||
|
paddingVertical: 12,
|
||||||
|
},
|
||||||
|
contentContainer: {
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 2,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
color: theme.centerChannelColor,
|
||||||
|
...typography('Body', 200, 'Regular'),
|
||||||
|
},
|
||||||
|
statusText: {
|
||||||
|
color: changeOpacity(theme.centerChannelColor, 0.64),
|
||||||
|
...typography('Body', 75, 'Regular'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const PlaybookRow = ({playbook, onPress, testID}: Props) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const intl = useIntl();
|
||||||
|
const styles = getStyleSheet(theme);
|
||||||
|
|
||||||
|
const handlePress = () => {
|
||||||
|
onPress?.(playbook);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatLastUsed = (lastRunAt: number) => {
|
||||||
|
if (!lastRunAt) {
|
||||||
|
return intl.formatMessage({
|
||||||
|
id: 'playbooks.row.never_used',
|
||||||
|
defaultMessage: 'Never used',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const formattedTime = getFriendlyDate(intl, lastRunAt);
|
||||||
|
|
||||||
|
return intl.formatMessage({
|
||||||
|
id: 'playbooks.row.last_used',
|
||||||
|
defaultMessage: 'Last used {time}',
|
||||||
|
}, {time: formattedTime});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatRunsInProgress = (activeRuns: number) => {
|
||||||
|
if (activeRuns === 0) {
|
||||||
|
return intl.formatMessage({
|
||||||
|
id: 'playbooks.row.no_runs',
|
||||||
|
defaultMessage: 'No runs in progress',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return intl.formatMessage({
|
||||||
|
id: 'playbooks.row.runs',
|
||||||
|
defaultMessage: '{count} {count, plural, one {run} other {runs}} in progress',
|
||||||
|
}, {count: activeRuns});
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusText = `${formatLastUsed(playbook.last_run_at)} • ${formatRunsInProgress(playbook.active_runs)}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handlePress}
|
||||||
|
style={styles.container}
|
||||||
|
testID={testID}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<CompassIcon
|
||||||
|
name={playbook.public ? 'book-outline' : 'book-lock-outline'}
|
||||||
|
size={24}
|
||||||
|
color={changeOpacity(theme.centerChannelColor, 0.64)}
|
||||||
|
/>
|
||||||
|
<View style={styles.contentContainer}>
|
||||||
|
<Text
|
||||||
|
style={styles.title}
|
||||||
|
numberOfLines={1}
|
||||||
|
ellipsizeMode='tail'
|
||||||
|
>
|
||||||
|
{playbook.title}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={styles.statusText}
|
||||||
|
numberOfLines={1}
|
||||||
|
ellipsizeMode='tail'
|
||||||
|
>
|
||||||
|
{statusText}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PlaybookRow;
|
||||||
|
|
@ -0,0 +1,352 @@
|
||||||
|
// 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 {FlatList, SectionList, Text, View, type DefaultSectionT, type ListRenderItemInfo, type SectionListData} from 'react-native';
|
||||||
|
import {SafeAreaView} from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
import {switchToChannelById} from '@actions/remote/channel';
|
||||||
|
import FormattedText from '@components/formatted_text';
|
||||||
|
import Loading from '@components/loading';
|
||||||
|
import SearchBar from '@components/search';
|
||||||
|
import {General, Screens} from '@constants';
|
||||||
|
import {useServerUrl} from '@context/server';
|
||||||
|
import {useTheme} from '@context/theme';
|
||||||
|
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
|
||||||
|
import SecurityManager from '@managers/security_manager';
|
||||||
|
import {fetchPlaybooks} from '@playbooks/actions/remote/playbooks';
|
||||||
|
import {fetchPlaybookRunsForChannel} from '@playbooks/actions/remote/runs';
|
||||||
|
import {
|
||||||
|
popTo,
|
||||||
|
popTopScreen,
|
||||||
|
} from '@screens/navigation';
|
||||||
|
import {changeOpacity, getKeyboardAppearanceFromTheme, makeStyleSheetFromTheme} from '@utils/theme';
|
||||||
|
import {typography} from '@utils/typography';
|
||||||
|
|
||||||
|
import {goToPlaybookRun, goToStartARun} from '../navigation';
|
||||||
|
|
||||||
|
import PlaybookRow from './playbook_row';
|
||||||
|
|
||||||
|
import type {AvailableScreens} from '@typings/screens/navigation';
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
popTopScreen();
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Props = {
|
||||||
|
currentTeamId: string;
|
||||||
|
currentUserId: string;
|
||||||
|
componentId: AvailableScreens;
|
||||||
|
playbooksUsedInChannel: Set<string>;
|
||||||
|
channelId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||||
|
return {
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingVertical: 24,
|
||||||
|
gap: 24,
|
||||||
|
},
|
||||||
|
searchBar: {
|
||||||
|
marginVertical: 5,
|
||||||
|
height: 38,
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: theme.centerChannelBg,
|
||||||
|
height: 70,
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
color: changeOpacity(theme.centerChannelColor, 0.6),
|
||||||
|
},
|
||||||
|
noResultContainer: {
|
||||||
|
flexGrow: 1,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
noResultText: {
|
||||||
|
color: changeOpacity(theme.centerChannelColor, 0.5),
|
||||||
|
...typography('Body', 600, 'Regular'),
|
||||||
|
},
|
||||||
|
searchBarInput: {
|
||||||
|
color: theme.centerChannelColor,
|
||||||
|
...typography('Body', 200, 'Regular'),
|
||||||
|
},
|
||||||
|
separator: {
|
||||||
|
height: 1,
|
||||||
|
flex: 0,
|
||||||
|
backgroundColor: changeOpacity(theme.centerChannelColor, 0.1),
|
||||||
|
},
|
||||||
|
sectionHeaderContainer: {
|
||||||
|
backgroundColor: theme.centerChannelBg,
|
||||||
|
paddingVertical: 8,
|
||||||
|
},
|
||||||
|
sectionHeader: {
|
||||||
|
color: changeOpacity(theme.centerChannelColor, 0.56),
|
||||||
|
...typography('Body', 75, 'SemiBold'),
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const EMPTY_DATA: Playbook[] = [];
|
||||||
|
|
||||||
|
function SelectPlaybook({
|
||||||
|
currentTeamId,
|
||||||
|
currentUserId,
|
||||||
|
componentId,
|
||||||
|
playbooksUsedInChannel,
|
||||||
|
channelId,
|
||||||
|
}: Props) {
|
||||||
|
const serverUrl = useServerUrl();
|
||||||
|
const theme = useTheme();
|
||||||
|
const searchTimeoutId = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const style = getStyleSheet(theme);
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
// HOOKS
|
||||||
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
|
const [searching, setSearching] = useState<boolean>(false);
|
||||||
|
const [term, setTerm] = useState<string>('');
|
||||||
|
const [data, setData] = useState<Playbook[]>([]);
|
||||||
|
const [searchResults, setSearchResults] = useState<Playbook[]>([]);
|
||||||
|
|
||||||
|
const page = useRef<number>(-1);
|
||||||
|
const next = useRef<boolean>(true);
|
||||||
|
|
||||||
|
// Callbacks
|
||||||
|
const clearSearch = useCallback(() => {
|
||||||
|
setTerm('');
|
||||||
|
setSearchResults([]);
|
||||||
|
setSearching(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadMore = useCallback(async () => {
|
||||||
|
if (next.current && !loading) {
|
||||||
|
setLoading(true);
|
||||||
|
const result = await fetchPlaybooks(serverUrl, {
|
||||||
|
team_id: currentTeamId,
|
||||||
|
page: page.current + 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.data) {
|
||||||
|
setData((prev) => [...prev, ...result.data.items]);
|
||||||
|
}
|
||||||
|
|
||||||
|
page.current++;
|
||||||
|
next.current = Boolean(result.data?.has_more);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [loading, serverUrl, currentTeamId]);
|
||||||
|
|
||||||
|
const onSearch = useCallback((text: string) => {
|
||||||
|
if (!text) {
|
||||||
|
clearSearch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTerm(text);
|
||||||
|
setSearching(true);
|
||||||
|
|
||||||
|
if (searchTimeoutId.current) {
|
||||||
|
clearTimeout(searchTimeoutId.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
searchTimeoutId.current = setTimeout(async () => {
|
||||||
|
const result = await fetchPlaybooks(serverUrl, {
|
||||||
|
team_id: currentTeamId,
|
||||||
|
search_term: text,
|
||||||
|
sort: 'last_run_at',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.data) {
|
||||||
|
setSearchResults(result.data.items);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSearching(false);
|
||||||
|
}, General.SEARCH_TIMEOUT_MILLISECONDS);
|
||||||
|
}, [clearSearch, serverUrl, currentTeamId]);
|
||||||
|
|
||||||
|
useAndroidHardwareBackHandler(componentId, close);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (searchTimeoutId.current) {
|
||||||
|
clearTimeout(searchTimeoutId.current);
|
||||||
|
searchTimeoutId.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadMore();
|
||||||
|
|
||||||
|
// We only want to load the playbooks once on mount
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const renderNoResults = useCallback((): JSX.Element | null => {
|
||||||
|
if (loading && page.current === -1) {
|
||||||
|
// Already handled by the loading component
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searching) {
|
||||||
|
return (
|
||||||
|
<Loading
|
||||||
|
color={theme.buttonBg}
|
||||||
|
containerStyle={style.loadingContainer}
|
||||||
|
size='large'
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={style.noResultContainer}>
|
||||||
|
<FormattedText
|
||||||
|
id='playbooks.create_run.select_playbook.no_results'
|
||||||
|
defaultMessage='No Results'
|
||||||
|
style={style.noResultText}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}, [loading, searching, style.loadingContainer, style.noResultContainer, style.noResultText, theme.buttonBg]);
|
||||||
|
|
||||||
|
const onRunCreated = useCallback(async (run: PlaybookRun) => {
|
||||||
|
await popTo(Screens.HOME);
|
||||||
|
await fetchPlaybookRunsForChannel(serverUrl, run.channel_id);
|
||||||
|
await switchToChannelById(serverUrl, run.channel_id);
|
||||||
|
await goToPlaybookRun(intl, run.id);
|
||||||
|
}, [intl, serverUrl]);
|
||||||
|
|
||||||
|
const onPress = useCallback((playbook: Playbook) => {
|
||||||
|
goToStartARun(intl, theme, playbook, onRunCreated, channelId);
|
||||||
|
}, [intl, onRunCreated, theme, channelId]);
|
||||||
|
|
||||||
|
const renderItem = useCallback(({item}: ListRenderItemInfo<Playbook>) => {
|
||||||
|
return (
|
||||||
|
<PlaybookRow
|
||||||
|
playbook={item}
|
||||||
|
onPress={onPress}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}, [onPress]);
|
||||||
|
|
||||||
|
const renderLoading = useCallback(() => {
|
||||||
|
if (!loading) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Loading
|
||||||
|
color={theme.buttonBg}
|
||||||
|
containerStyle={style.loadingContainer}
|
||||||
|
size='large'
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}, [loading, style.loadingContainer, theme.buttonBg]);
|
||||||
|
|
||||||
|
const renderSectionHeader = useCallback(({section}: {section: SectionListData<Playbook, DefaultSectionT>}) => {
|
||||||
|
return (
|
||||||
|
<View style={style.sectionHeaderContainer}>
|
||||||
|
<Text style={style.sectionHeader}>{section.title}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}, [style]);
|
||||||
|
|
||||||
|
const sections: Array<SectionListData<Playbook, DefaultSectionT>> = useMemo(() => {
|
||||||
|
type PlaybookSections = {
|
||||||
|
inThisChannel: Playbook[];
|
||||||
|
yourPlaybooks: Playbook[];
|
||||||
|
otherPlaybooks: Playbook[];
|
||||||
|
}
|
||||||
|
const reducedPlaybooks = data.reduce<PlaybookSections>((acc, playbook) => {
|
||||||
|
function isMember(member: PlaybookMember) {
|
||||||
|
return member.user_id === currentUserId;
|
||||||
|
}
|
||||||
|
if (playbooksUsedInChannel.has(playbook.id)) {
|
||||||
|
acc.inThisChannel.push(playbook);
|
||||||
|
} else if (playbook.members.some(isMember)) {
|
||||||
|
acc.yourPlaybooks.push(playbook);
|
||||||
|
} else {
|
||||||
|
acc.otherPlaybooks.push(playbook);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {inThisChannel: [], yourPlaybooks: [], otherPlaybooks: []});
|
||||||
|
const allSections = [
|
||||||
|
{
|
||||||
|
title: intl.formatMessage({id: 'playbooks.select_playbook.in_this_channel', defaultMessage: 'In This Channel'}),
|
||||||
|
data: reducedPlaybooks.inThisChannel,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: intl.formatMessage({id: 'playbooks.select_playbook.your_playbooks', defaultMessage: 'Your Playbooks'}),
|
||||||
|
data: reducedPlaybooks.yourPlaybooks,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: intl.formatMessage({id: 'playbooks.select_playbook.other_playbooks', defaultMessage: 'Other Playbooks'}),
|
||||||
|
data: reducedPlaybooks.otherPlaybooks,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return allSections.filter((section) => section.data.length > 0);
|
||||||
|
}, [currentUserId, data, intl, playbooksUsedInChannel]);
|
||||||
|
|
||||||
|
let shownData = EMPTY_DATA;
|
||||||
|
if (!loading) {
|
||||||
|
if (term) {
|
||||||
|
shownData = searchResults;
|
||||||
|
} else {
|
||||||
|
shownData = data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView
|
||||||
|
nativeID={SecurityManager.getShieldScreenId(componentId)}
|
||||||
|
style={style.container}
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
testID='integration_selector.screen'
|
||||||
|
style={style.searchBar}
|
||||||
|
>
|
||||||
|
<SearchBar
|
||||||
|
testID='selector.search_bar'
|
||||||
|
placeholder={intl.formatMessage({id: 'search_bar.search', defaultMessage: 'Search'})}
|
||||||
|
inputStyle={style.searchBarInput}
|
||||||
|
placeholderTextColor={changeOpacity(theme.centerChannelColor, 0.5)}
|
||||||
|
onChangeText={onSearch}
|
||||||
|
autoCapitalize='none'
|
||||||
|
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
|
||||||
|
value={term}
|
||||||
|
showLoading={searching}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
{term && (
|
||||||
|
<FlatList
|
||||||
|
data={shownData}
|
||||||
|
renderItem={renderItem}
|
||||||
|
ListEmptyComponent={renderNoResults}
|
||||||
|
onEndReached={loadMore}
|
||||||
|
ListFooterComponent={renderLoading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!term && (
|
||||||
|
<SectionList
|
||||||
|
sections={sections}
|
||||||
|
renderSectionHeader={renderSectionHeader}
|
||||||
|
renderItem={renderItem}
|
||||||
|
ListEmptyComponent={renderNoResults}
|
||||||
|
onEndReached={loadMore}
|
||||||
|
ListFooterComponent={renderLoading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SelectPlaybook;
|
||||||
19
app/products/playbooks/screens/start_a_run/index.ts
Normal file
19
app/products/playbooks/screens/start_a_run/index.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
|
||||||
|
|
||||||
|
import {observeCurrentUserId, observeCurrentTeamId} from '@queries/servers/system';
|
||||||
|
|
||||||
|
import StartARun from './start_a_run';
|
||||||
|
|
||||||
|
import type {WithDatabaseArgs} from '@typings/database/database';
|
||||||
|
|
||||||
|
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
|
||||||
|
return {
|
||||||
|
currentUserId: observeCurrentUserId(database),
|
||||||
|
currentTeamId: observeCurrentTeamId(database),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export default withDatabase(enhanced(StartARun));
|
||||||
282
app/products/playbooks/screens/start_a_run/start_a_run.tsx
Normal file
282
app/products/playbooks/screens/start_a_run/start_a_run.tsx
Normal file
|
|
@ -0,0 +1,282 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import React, {useState, useCallback, useEffect} from 'react';
|
||||||
|
import {useIntl, type IntlShape} from 'react-intl';
|
||||||
|
import {ScrollView, View} from 'react-native';
|
||||||
|
import {SafeAreaView} from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
import CompassIcon from '@components/compass_icon';
|
||||||
|
import FloatingAutocompleteSelector from '@components/floating_input/floating_autocomplete_selector';
|
||||||
|
import FloatingTextInput from '@components/floating_input/floating_text_input_label';
|
||||||
|
import OptionItem from '@components/option_item';
|
||||||
|
import {useServerUrl} from '@context/server';
|
||||||
|
import {useTheme} from '@context/theme';
|
||||||
|
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
|
||||||
|
import useNavButtonPressed from '@hooks/navigation_button_pressed';
|
||||||
|
import {createPlaybookRun} from '@playbooks/actions/remote/runs';
|
||||||
|
import {popTopScreen, setButtons} from '@screens/navigation';
|
||||||
|
import {getFullErrorMessage} from '@utils/errors';
|
||||||
|
import {logDebug} from '@utils/log';
|
||||||
|
import {showPlaybookErrorSnackbar} from '@utils/snack_bar';
|
||||||
|
import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||||
|
|
||||||
|
import type {AvailableScreens} from '@typings/screens/navigation';
|
||||||
|
import type {OptionsTopBarButton} from 'react-native-navigation';
|
||||||
|
|
||||||
|
type ChannelOption = 'existing' | 'new';
|
||||||
|
|
||||||
|
export type Props = {
|
||||||
|
componentId: AvailableScreens;
|
||||||
|
playbook: Playbook;
|
||||||
|
currentUserId: string;
|
||||||
|
currentTeamId: string;
|
||||||
|
onRunCreated: (run: PlaybookRun) => void;
|
||||||
|
channelId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||||
|
return {
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: theme.centerChannelBg,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingVertical: 24,
|
||||||
|
},
|
||||||
|
contentContainer: {
|
||||||
|
gap: 16,
|
||||||
|
},
|
||||||
|
channelInput: {
|
||||||
|
marginLeft: 40, // Align with radio button text
|
||||||
|
},
|
||||||
|
channelTypeSelectorSection: {
|
||||||
|
gap: 16,
|
||||||
|
marginLeft: 40,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const CLOSE_BUTTON_ID = 'close-start-a-run';
|
||||||
|
const CREATE_BUTTON_ID = 'create-run';
|
||||||
|
|
||||||
|
async function makeLeftButton(theme: Theme): Promise<OptionsTopBarButton> {
|
||||||
|
return {
|
||||||
|
id: CLOSE_BUTTON_ID,
|
||||||
|
icon: await CompassIcon.getImageSource('close', 24, theme.sidebarHeaderTextColor),
|
||||||
|
testID: 'start_a_run.close.button',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRightButton(theme: Theme, intl: IntlShape, enabled: boolean): OptionsTopBarButton {
|
||||||
|
return {
|
||||||
|
color: theme.sidebarHeaderTextColor,
|
||||||
|
id: CREATE_BUTTON_ID,
|
||||||
|
text: intl.formatMessage({id: 'mobile.create_channel', defaultMessage: 'Create'}),
|
||||||
|
showAsAction: 'always',
|
||||||
|
testID: 'start_a_run.create.button',
|
||||||
|
enabled,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function StartARun({
|
||||||
|
componentId,
|
||||||
|
playbook,
|
||||||
|
currentUserId,
|
||||||
|
currentTeamId,
|
||||||
|
onRunCreated,
|
||||||
|
channelId,
|
||||||
|
}: Props) {
|
||||||
|
const theme = useTheme();
|
||||||
|
const intl = useIntl();
|
||||||
|
const styles = getStyleSheet(theme);
|
||||||
|
const serverUrl = useServerUrl();
|
||||||
|
|
||||||
|
const [runName, setRunName] = useState(() => {
|
||||||
|
if (playbook?.channel_mode === 'create_new_channel') {
|
||||||
|
return playbook.channel_name_template || '';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
const [runDescription, setRunDescription] = useState(() => {
|
||||||
|
if (playbook?.run_summary_template_enabled) {
|
||||||
|
return playbook.run_summary_template || '';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
const [channelOption, setChannelOption] = useState<ChannelOption>('existing');
|
||||||
|
const [selectedChannelId, setSelectedChannelId] = useState<string | undefined>(channelId);
|
||||||
|
const [createPublicChannel, setCreatePublicChannel] = useState(false);
|
||||||
|
|
||||||
|
const canSave = Boolean(runName.trim());
|
||||||
|
|
||||||
|
const handleStartRun = useCallback(async () => {
|
||||||
|
if (!runName.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await createPlaybookRun(serverUrl, playbook.id, currentUserId, currentTeamId, runName.trim(), runDescription.trim(), selectedChannelId, channelOption === 'new' ? createPublicChannel : undefined);
|
||||||
|
if (res.error || !res.data) {
|
||||||
|
logDebug('error on createPlaybookRun', getFullErrorMessage(res.error));
|
||||||
|
showPlaybookErrorSnackbar();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await popTopScreen(componentId);
|
||||||
|
onRunCreated(res.data);
|
||||||
|
}, [runName, serverUrl, playbook.id, currentUserId, currentTeamId, runDescription, selectedChannelId, channelOption, createPublicChannel, componentId, onRunCreated]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function asyncWrapper() {
|
||||||
|
const leftButton = await makeLeftButton(theme);
|
||||||
|
const rightButton = makeRightButton(theme, intl, canSave);
|
||||||
|
|
||||||
|
setButtons(
|
||||||
|
componentId,
|
||||||
|
{
|
||||||
|
leftButtons: [leftButton],
|
||||||
|
rightButtons: [rightButton],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
asyncWrapper();
|
||||||
|
}, [componentId, theme, intl, canSave]);
|
||||||
|
|
||||||
|
const close = useCallback(() => {
|
||||||
|
popTopScreen(componentId);
|
||||||
|
}, [componentId]);
|
||||||
|
|
||||||
|
useNavButtonPressed(CREATE_BUTTON_ID, componentId, handleStartRun, [handleStartRun]);
|
||||||
|
useNavButtonPressed(CLOSE_BUTTON_ID, componentId, close, [close]);
|
||||||
|
useAndroidHardwareBackHandler(componentId, close);
|
||||||
|
|
||||||
|
const existingOptionAction = useCallback(() => {
|
||||||
|
setChannelOption('existing');
|
||||||
|
}, []);
|
||||||
|
const newOptionAction = useCallback(() => {
|
||||||
|
setChannelOption('new');
|
||||||
|
}, []);
|
||||||
|
const publicChannelOptionAction = useCallback(() => {
|
||||||
|
setCreatePublicChannel(true);
|
||||||
|
}, []);
|
||||||
|
const privateChannelOptionAction = useCallback(() => {
|
||||||
|
setCreatePublicChannel(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onChannelSelected = useCallback((value: SelectedDialogOption) => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
// Multiselect case, should never happen
|
||||||
|
logDebug('on channel selected returned an array, this should never happen', value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!value) {
|
||||||
|
// Undefined case, should never happen
|
||||||
|
logDebug('on channel selected returned undefined, this should never happen');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedChannelId(value.value);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.container}>
|
||||||
|
<ScrollView
|
||||||
|
style={styles.content}
|
||||||
|
contentContainerStyle={styles.contentContainer}
|
||||||
|
>
|
||||||
|
<FloatingTextInput
|
||||||
|
label={intl.formatMessage({
|
||||||
|
id: 'playbooks.start_run.run_name_label',
|
||||||
|
defaultMessage: 'Run name',
|
||||||
|
})}
|
||||||
|
placeholder={intl.formatMessage({
|
||||||
|
id: 'playbooks.start_run.run_name_placeholder',
|
||||||
|
defaultMessage: 'Add a name for your run',
|
||||||
|
})}
|
||||||
|
value={runName}
|
||||||
|
onChangeText={setRunName}
|
||||||
|
theme={theme}
|
||||||
|
testID='start_run.run_name_input'
|
||||||
|
error={runName.trim() ? undefined : intl.formatMessage({
|
||||||
|
id: 'playbooks.start_run.run_name_error',
|
||||||
|
defaultMessage: 'Please add a name for this run',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<FloatingTextInput
|
||||||
|
label={intl.formatMessage({
|
||||||
|
id: 'playbooks.start_run.run_description_label',
|
||||||
|
defaultMessage: 'Run description',
|
||||||
|
})}
|
||||||
|
value={runDescription}
|
||||||
|
onChangeText={setRunDescription}
|
||||||
|
multiline={true}
|
||||||
|
multilineInputHeight={100}
|
||||||
|
theme={theme}
|
||||||
|
testID='start_run.run_description_input'
|
||||||
|
/>
|
||||||
|
|
||||||
|
<OptionItem
|
||||||
|
label={intl.formatMessage({
|
||||||
|
id: 'playbooks.start_run.link_existing_channel',
|
||||||
|
defaultMessage: 'Link to an existing channel',
|
||||||
|
})}
|
||||||
|
type='radio'
|
||||||
|
selected={channelOption === 'existing'}
|
||||||
|
action={existingOptionAction}
|
||||||
|
testID='start_run.existing_channel_option'
|
||||||
|
/>
|
||||||
|
{channelOption === 'existing' && (
|
||||||
|
<View style={styles.channelInput}>
|
||||||
|
<FloatingAutocompleteSelector
|
||||||
|
label={intl.formatMessage({
|
||||||
|
id: 'playbooks.start_run.channel_label',
|
||||||
|
defaultMessage: 'Channel',
|
||||||
|
})}
|
||||||
|
dataSource='channels'
|
||||||
|
selected={selectedChannelId}
|
||||||
|
onSelected={onChannelSelected}
|
||||||
|
testID='start_run.existing_channel_selector'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<OptionItem
|
||||||
|
label={intl.formatMessage({
|
||||||
|
id: 'playbooks.start_run.create_new_channel',
|
||||||
|
defaultMessage: 'Create a new channel',
|
||||||
|
})}
|
||||||
|
type='radio'
|
||||||
|
selected={channelOption === 'new'}
|
||||||
|
action={newOptionAction}
|
||||||
|
testID='start_run.new_channel_option'
|
||||||
|
/>
|
||||||
|
{channelOption === 'new' && (
|
||||||
|
<View style={styles.channelTypeSelectorSection}>
|
||||||
|
<OptionItem
|
||||||
|
label={intl.formatMessage({
|
||||||
|
id: 'playbooks.start_run.create_new_channel.public_channel',
|
||||||
|
defaultMessage: 'Public channel',
|
||||||
|
})}
|
||||||
|
type='radio'
|
||||||
|
selected={createPublicChannel}
|
||||||
|
action={publicChannelOptionAction}
|
||||||
|
testID='start_run.new_channel_public_option'
|
||||||
|
/>
|
||||||
|
<OptionItem
|
||||||
|
label={intl.formatMessage({
|
||||||
|
id: 'playbooks.start_run.create_new_channel.private_channel',
|
||||||
|
defaultMessage: 'Private channel',
|
||||||
|
})}
|
||||||
|
type='radio'
|
||||||
|
selected={!createPublicChannel}
|
||||||
|
action={privateChannelOptionAction}
|
||||||
|
testID='start_run.new_channel_private_option'
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default StartARun;
|
||||||
41
app/products/playbooks/types/api.d.ts
vendored
41
app/products/playbooks/types/api.d.ts
vendored
|
|
@ -54,6 +54,7 @@ type RunMetricData = {
|
||||||
type StatusPost = {
|
type StatusPost = {
|
||||||
id: string;
|
id: string;
|
||||||
create_at: number;
|
create_at: number;
|
||||||
|
delete_at: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TimelineEvent = {
|
type TimelineEvent = {
|
||||||
|
|
@ -135,4 +136,44 @@ type PlaybookRun = {
|
||||||
metrics_data: RunMetricData[];
|
metrics_data: RunMetricData[];
|
||||||
update_at: number;
|
update_at: number;
|
||||||
items_order: string[];
|
items_order: string[];
|
||||||
|
status_update_broadcast_channels_enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlaybookRunMetadata = {
|
||||||
|
channel_name: string;
|
||||||
|
channel_display_name: string;
|
||||||
|
team_name: string;
|
||||||
|
num_participants: number;
|
||||||
|
total_posts: number;
|
||||||
|
followers: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type Playbook = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
team_id: string;
|
||||||
|
create_public_playbook_run: boolean;
|
||||||
|
delete_at: number;
|
||||||
|
run_summary_template_enabled: boolean;
|
||||||
|
run_summary_template: string;
|
||||||
|
channel_name_template: string;
|
||||||
|
channel_mode: string;
|
||||||
|
public: boolean;
|
||||||
|
default_owner_id: string;
|
||||||
|
default_owner_enabled: boolean;
|
||||||
|
num_stages: number;
|
||||||
|
num_steps: number;
|
||||||
|
num_runs: number;
|
||||||
|
num_actions: number;
|
||||||
|
last_run_at: number;
|
||||||
|
members: PlaybookMember[];
|
||||||
|
default_playbook_member_role: string;
|
||||||
|
active_runs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlaybookMember = {
|
||||||
|
user_id: string;
|
||||||
|
roles: string[];
|
||||||
|
scheme_roles?: string[];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
29
app/products/playbooks/types/client.d.ts
vendored
29
app/products/playbooks/types/client.d.ts
vendored
|
|
@ -35,3 +35,32 @@ type FetchPlaybookRunsParams = {
|
||||||
channel_id?: string;
|
channel_id?: string;
|
||||||
since?: number;
|
since?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FetchPlaybooksParams = {
|
||||||
|
team_id: string;
|
||||||
|
page?: number;
|
||||||
|
per_page?: number;
|
||||||
|
sort?: 'title' | 'stages' | 'steps' | 'runs' | 'last_run_at' | 'active_runs';
|
||||||
|
direction?: 'asc' | 'desc';
|
||||||
|
search_term?: string;
|
||||||
|
with_archived?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type FetchPlaybooksReturn = {
|
||||||
|
total_count: number;
|
||||||
|
page_count: number;
|
||||||
|
has_more: boolean;
|
||||||
|
items: Playbook[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type PostStatusUpdatePayload = {
|
||||||
|
message: string;
|
||||||
|
reminder?: number;
|
||||||
|
finishRun: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PostStatusUpdateIds = {
|
||||||
|
user_id: string;
|
||||||
|
channel_id: string;
|
||||||
|
team_id: string;
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -70,3 +70,7 @@ export function isDueSoon(item: PlaybookChecklistItemModel | PlaybookChecklistIt
|
||||||
|
|
||||||
return dueDate < Date.now() + toMilliseconds({hours: 12});
|
return dueDate < Date.now() + toMilliseconds({hours: 12});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isOutstanding(item: PlaybookChecklistItemModel | PlaybookChecklistItem): boolean {
|
||||||
|
return item.state === '' || item.state === 'in_progress';
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,169 +0,0 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
||||||
// See LICENSE.txt for license information.
|
|
||||||
|
|
||||||
import {of as of$} from 'rxjs';
|
|
||||||
|
|
||||||
import DatabaseManager from '@database/manager';
|
|
||||||
|
|
||||||
import {observeLowConnectivityMonitor} from './global';
|
|
||||||
|
|
||||||
import type {AppDatabase} from '@typings/database/database';
|
|
||||||
|
|
||||||
jest.mock('@database/manager', () => ({
|
|
||||||
getAppDatabaseAndOperator: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('observeLowConnectivityMonitor', () => {
|
|
||||||
const mockDatabase = {
|
|
||||||
get: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockQuery = {
|
|
||||||
query: jest.fn(),
|
|
||||||
observe: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
jest.mocked(DatabaseManager.getAppDatabaseAndOperator).mockReturnValue({
|
|
||||||
database: mockDatabase,
|
|
||||||
operator: {},
|
|
||||||
} as unknown as AppDatabase);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return observable that defaults to true when no database record exists', (done) => {
|
|
||||||
mockDatabase.get.mockReturnValue({
|
|
||||||
query: jest.fn().mockReturnValue(mockQuery),
|
|
||||||
});
|
|
||||||
mockQuery.observe.mockReturnValue(of$([]));
|
|
||||||
|
|
||||||
const observable = observeLowConnectivityMonitor();
|
|
||||||
|
|
||||||
observable.subscribe((value) => {
|
|
||||||
expect(value).toBe(true);
|
|
||||||
done();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return observable with database value when record exists', (done) => {
|
|
||||||
const mockRecord = {
|
|
||||||
observe: jest.fn().mockReturnValue(of$({value: false})),
|
|
||||||
};
|
|
||||||
|
|
||||||
mockDatabase.get.mockReturnValue({
|
|
||||||
query: jest.fn().mockReturnValue(mockQuery),
|
|
||||||
});
|
|
||||||
mockQuery.observe.mockReturnValue(of$([mockRecord]));
|
|
||||||
|
|
||||||
const observable = observeLowConnectivityMonitor();
|
|
||||||
|
|
||||||
observable.subscribe((value) => {
|
|
||||||
expect(value).toBe(false);
|
|
||||||
done();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle boolean value directly', (done) => {
|
|
||||||
const mockRecord = {
|
|
||||||
observe: jest.fn().mockReturnValue(of$(true)),
|
|
||||||
};
|
|
||||||
|
|
||||||
mockDatabase.get.mockReturnValue({
|
|
||||||
query: jest.fn().mockReturnValue(mockQuery),
|
|
||||||
});
|
|
||||||
mockQuery.observe.mockReturnValue(of$([mockRecord]));
|
|
||||||
|
|
||||||
const observable = observeLowConnectivityMonitor();
|
|
||||||
|
|
||||||
observable.subscribe((value) => {
|
|
||||||
expect(value).toBe(true);
|
|
||||||
done();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle false boolean value directly', (done) => {
|
|
||||||
const mockRecord = {
|
|
||||||
observe: jest.fn().mockReturnValue(of$(false)),
|
|
||||||
};
|
|
||||||
|
|
||||||
mockDatabase.get.mockReturnValue({
|
|
||||||
query: jest.fn().mockReturnValue(mockQuery),
|
|
||||||
});
|
|
||||||
mockQuery.observe.mockReturnValue(of$([mockRecord]));
|
|
||||||
|
|
||||||
const observable = observeLowConnectivityMonitor();
|
|
||||||
|
|
||||||
observable.subscribe((value) => {
|
|
||||||
expect(value).toBe(false);
|
|
||||||
done();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should default to true when record value is undefined', (done) => {
|
|
||||||
const mockRecord = {
|
|
||||||
observe: jest.fn().mockReturnValue(of$({value: undefined})),
|
|
||||||
};
|
|
||||||
|
|
||||||
mockDatabase.get.mockReturnValue({
|
|
||||||
query: jest.fn().mockReturnValue(mockQuery),
|
|
||||||
});
|
|
||||||
mockQuery.observe.mockReturnValue(of$([mockRecord]));
|
|
||||||
|
|
||||||
const observable = observeLowConnectivityMonitor();
|
|
||||||
|
|
||||||
observable.subscribe((value) => {
|
|
||||||
expect(value).toBe(true);
|
|
||||||
done();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should default to true when query returns null', (done) => {
|
|
||||||
jest.mocked(DatabaseManager.getAppDatabaseAndOperator).mockImplementation(() => {
|
|
||||||
throw new Error('Database not found');
|
|
||||||
});
|
|
||||||
|
|
||||||
const observable = observeLowConnectivityMonitor();
|
|
||||||
|
|
||||||
observable.subscribe((value) => {
|
|
||||||
expect(value).toBe(true);
|
|
||||||
done();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle enabled state (true)', (done) => {
|
|
||||||
const mockRecord = {
|
|
||||||
observe: jest.fn().mockReturnValue(of$({value: true})),
|
|
||||||
};
|
|
||||||
|
|
||||||
mockDatabase.get.mockReturnValue({
|
|
||||||
query: jest.fn().mockReturnValue(mockQuery),
|
|
||||||
});
|
|
||||||
mockQuery.observe.mockReturnValue(of$([mockRecord]));
|
|
||||||
|
|
||||||
const observable = observeLowConnectivityMonitor();
|
|
||||||
|
|
||||||
observable.subscribe((value) => {
|
|
||||||
expect(value).toBe(true);
|
|
||||||
done();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle disabled state (false)', (done) => {
|
|
||||||
const mockRecord = {
|
|
||||||
observe: jest.fn().mockReturnValue(of$({value: false})),
|
|
||||||
};
|
|
||||||
|
|
||||||
mockDatabase.get.mockReturnValue({
|
|
||||||
query: jest.fn().mockReturnValue(mockQuery),
|
|
||||||
});
|
|
||||||
mockQuery.observe.mockReturnValue(of$([mockRecord]));
|
|
||||||
|
|
||||||
const observable = observeLowConnectivityMonitor();
|
|
||||||
|
|
||||||
observable.subscribe((value) => {
|
|
||||||
expect(value).toBe(false);
|
|
||||||
done();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
@ -100,19 +100,3 @@ export const observeTutorialWatched = (tutorial: string) => {
|
||||||
switchMap((v) => of$(Boolean(v))),
|
switchMap((v) => of$(Boolean(v))),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const observeLowConnectivityMonitor = () => {
|
|
||||||
const query = queryGlobalValue(GLOBAL_IDENTIFIERS.LOW_CONNECTIVITY_MONITOR);
|
|
||||||
if (!query) {
|
|
||||||
return of$(true);
|
|
||||||
}
|
|
||||||
return query.observe().pipe(
|
|
||||||
switchMap((result) => (result.length ? result[0].observe() : of$(true))),
|
|
||||||
switchMap((v) => {
|
|
||||||
if (typeof v === 'boolean') {
|
|
||||||
return of$(v);
|
|
||||||
}
|
|
||||||
return of$(v?.value ?? true);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,8 @@ export function ChannelBanner({bannerInfo, isTopItem}: Props) {
|
||||||
}), [bannerInfo?.background_color, defaultHeight, style.container]);
|
}), [bannerInfo?.background_color, defaultHeight, style.container]);
|
||||||
|
|
||||||
const markdownTextStyle = useMemo(() => {
|
const markdownTextStyle = useMemo(() => {
|
||||||
const textStyle = getMarkdownTextStyles(theme);
|
// We do a shallow copy to avoid mutating the original object.
|
||||||
|
const textStyle = {...getMarkdownTextStyles(theme)};
|
||||||
|
|
||||||
// channel banner colors are theme independent.
|
// channel banner colors are theme independent.
|
||||||
// If we let the link color being set by the theme, it will be unreadable in some cases.
|
// If we let the link color being set by the theme, it will be unreadable in some cases.
|
||||||
|
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
||||||
// See LICENSE.txt for license information.
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
import FloatingBanner from '@components/floating_banner/floating_banner';
|
|
||||||
|
|
||||||
import type {FloatingBannerConfig} from '@components/floating_banner/types';
|
|
||||||
|
|
||||||
type FloatingBannerScreenProps = {
|
|
||||||
banners: FloatingBannerConfig[];
|
|
||||||
onDismiss: (id: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const FloatingBannerScreen: React.FC<FloatingBannerScreenProps> = (props) => {
|
|
||||||
return <FloatingBanner {...props}/>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default FloatingBannerScreen;
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {CHANNEL, DRAFT, THREAD} from '@constants/screens';
|
||||||
import {TABLET_SIDEBAR_WIDTH, TEAM_SIDEBAR_WIDTH} from '@constants/view';
|
import {TABLET_SIDEBAR_WIDTH, TEAM_SIDEBAR_WIDTH} from '@constants/view';
|
||||||
import {useTheme} from '@context/theme';
|
import {useTheme} from '@context/theme';
|
||||||
import {useIsTablet} from '@hooks/device';
|
import {useIsTablet} from '@hooks/device';
|
||||||
|
import PlaybooksButton from '@playbooks/components/playbooks_button';
|
||||||
import {makeStyleSheetFromTheme} from '@utils/theme';
|
import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||||
|
|
||||||
import Categories from './categories';
|
import Categories from './categories';
|
||||||
|
|
@ -37,6 +38,7 @@ type ChannelListProps = {
|
||||||
scheduledPostHasError: boolean;
|
scheduledPostHasError: boolean;
|
||||||
lastChannelId?: string;
|
lastChannelId?: string;
|
||||||
scheduledPostsEnabled?: boolean;
|
scheduledPostsEnabled?: boolean;
|
||||||
|
playbooksEnabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getTabletWidth = (moreThanOneTeam: boolean) => {
|
const getTabletWidth = (moreThanOneTeam: boolean) => {
|
||||||
|
|
@ -55,6 +57,7 @@ const CategoriesList = ({
|
||||||
scheduledPostHasError,
|
scheduledPostHasError,
|
||||||
lastChannelId,
|
lastChannelId,
|
||||||
scheduledPostsEnabled,
|
scheduledPostsEnabled,
|
||||||
|
playbooksEnabled,
|
||||||
}: ChannelListProps) => {
|
}: ChannelListProps) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const styles = getStyleSheet(theme);
|
const styles = getStyleSheet(theme);
|
||||||
|
|
@ -124,6 +127,16 @@ const CategoriesList = ({
|
||||||
return null;
|
return null;
|
||||||
}, [activeScreen, draftsCount, isTablet, scheduledPostCount, scheduledPostHasError, scheduledPostsEnabled]);
|
}, [activeScreen, draftsCount, isTablet, scheduledPostCount, scheduledPostHasError, scheduledPostsEnabled]);
|
||||||
|
|
||||||
|
const playbooksButtonComponent = useMemo(() => {
|
||||||
|
if (!playbooksEnabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PlaybooksButton/>
|
||||||
|
);
|
||||||
|
}, [playbooksEnabled]);
|
||||||
|
|
||||||
const content = useMemo(() => {
|
const content = useMemo(() => {
|
||||||
if (!hasChannels) {
|
if (!hasChannels) {
|
||||||
return (<LoadChannelsError/>);
|
return (<LoadChannelsError/>);
|
||||||
|
|
@ -134,10 +147,11 @@ const CategoriesList = ({
|
||||||
<SubHeader/>
|
<SubHeader/>
|
||||||
{threadButtonComponent}
|
{threadButtonComponent}
|
||||||
{draftsButtonComponent}
|
{draftsButtonComponent}
|
||||||
|
{playbooksButtonComponent}
|
||||||
<Categories/>
|
<Categories/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}, [draftsButtonComponent, hasChannels, threadButtonComponent]);
|
}, [draftsButtonComponent, hasChannels, playbooksButtonComponent, threadButtonComponent]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View style={[styles.container, tabletStyle]}>
|
<Animated.View style={[styles.container, tabletStyle]}>
|
||||||
|
|
|
||||||
|
|
@ -170,4 +170,34 @@ describe('components/categories_list', () => {
|
||||||
);
|
);
|
||||||
expect(wrapper.queryByText('Drafts')).not.toBeTruthy();
|
expect(wrapper.queryByText('Drafts')).not.toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should not render channel list with Playbooks menu if playbooks feature is disabled', () => {
|
||||||
|
const wrapper = renderWithEverything(
|
||||||
|
<CategoriesList
|
||||||
|
moreThanOneTeam={false}
|
||||||
|
hasChannels={true}
|
||||||
|
draftsCount={0}
|
||||||
|
scheduledPostCount={0}
|
||||||
|
scheduledPostHasError={false}
|
||||||
|
playbooksEnabled={false}
|
||||||
|
/>,
|
||||||
|
{database},
|
||||||
|
);
|
||||||
|
expect(wrapper.queryByText('Playbook runs')).not.toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render channel list with Playbooks menu if playbooks feature is enabled', () => {
|
||||||
|
const wrapper = renderWithEverything(
|
||||||
|
<CategoriesList
|
||||||
|
moreThanOneTeam={false}
|
||||||
|
hasChannels={true}
|
||||||
|
draftsCount={0}
|
||||||
|
scheduledPostCount={0}
|
||||||
|
scheduledPostHasError={false}
|
||||||
|
playbooksEnabled={true}
|
||||||
|
/>,
|
||||||
|
{database},
|
||||||
|
);
|
||||||
|
expect(wrapper.getByText('Playbook runs')).toBeTruthy();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
|
||||||
import {of} from 'rxjs';
|
import {of} from 'rxjs';
|
||||||
import {switchMap} from 'rxjs/operators';
|
import {switchMap} from 'rxjs/operators';
|
||||||
|
|
||||||
|
import {observeIsPlaybooksEnabled} from '@playbooks/database/queries/version';
|
||||||
import {observeDraftCount} from '@queries/servers/drafts';
|
import {observeDraftCount} from '@queries/servers/drafts';
|
||||||
import {observeScheduledPostEnabled, observeScheduledPostsForTeam} from '@queries/servers/scheduled_post';
|
import {observeScheduledPostEnabled, observeScheduledPostsForTeam} from '@queries/servers/scheduled_post';
|
||||||
import {observeCurrentTeamId} from '@queries/servers/system';
|
import {observeCurrentTeamId} from '@queries/servers/system';
|
||||||
|
|
@ -27,6 +28,7 @@ const enchanced = withObservables([], ({database}: WithDatabaseArgs) => {
|
||||||
switchMap((scheduledPosts) => of(hasScheduledPostError(scheduledPosts))),
|
switchMap((scheduledPosts) => of(hasScheduledPostError(scheduledPosts))),
|
||||||
);
|
);
|
||||||
const scheduledPostsEnabled = observeScheduledPostEnabled(database);
|
const scheduledPostsEnabled = observeScheduledPostEnabled(database);
|
||||||
|
const playbooksEnabled = observeIsPlaybooksEnabled(database);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
lastChannelId,
|
lastChannelId,
|
||||||
|
|
@ -34,6 +36,7 @@ const enchanced = withObservables([], ({database}: WithDatabaseArgs) => {
|
||||||
scheduledPostCount,
|
scheduledPostCount,
|
||||||
scheduledPostHasError,
|
scheduledPostHasError,
|
||||||
scheduledPostsEnabled,
|
scheduledPostsEnabled,
|
||||||
|
playbooksEnabled,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {type Edge, SafeAreaView, useSafeAreaInsets} from 'react-native-safe-area
|
||||||
import {refetchCurrentUser} from '@actions/remote/user';
|
import {refetchCurrentUser} from '@actions/remote/user';
|
||||||
import FloatingCallContainer from '@calls/components/floating_call_container';
|
import FloatingCallContainer from '@calls/components/floating_call_container';
|
||||||
import AnnouncementBanner from '@components/announcement_banner';
|
import AnnouncementBanner from '@components/announcement_banner';
|
||||||
|
import ConnectionBanner from '@components/connection_banner';
|
||||||
import TeamSidebar from '@components/team_sidebar';
|
import TeamSidebar from '@components/team_sidebar';
|
||||||
import {Navigation as NavigationConstants, Screens} from '@constants';
|
import {Navigation as NavigationConstants, Screens} from '@constants';
|
||||||
import {useServerUrl} from '@context/server';
|
import {useServerUrl} from '@context/server';
|
||||||
|
|
@ -156,7 +157,7 @@ const ChannelListScreen = (props: ChannelProps) => {
|
||||||
if (!props.hasCurrentUser || !props.currentUserId) {
|
if (!props.hasCurrentUser || !props.currentUserId) {
|
||||||
refetchCurrentUser(serverUrl, props.currentUserId);
|
refetchCurrentUser(serverUrl, props.currentUserId);
|
||||||
}
|
}
|
||||||
}, [props.currentUserId, props.hasCurrentUser, serverUrl]);
|
}, [props.currentUserId, props.hasCurrentUser]);
|
||||||
|
|
||||||
// Init the rate app. Only run the effect on the first render if ToS is not open
|
// Init the rate app. Only run the effect on the first render if ToS is not open
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -167,12 +168,12 @@ const ChannelListScreen = (props: ChannelProps) => {
|
||||||
if (!NavigationStore.isToSOpen()) {
|
if (!NavigationStore.isToSOpen()) {
|
||||||
tryRunAppReview(props.launchType, props.coldStart);
|
tryRunAppReview(props.launchType, props.coldStart);
|
||||||
}
|
}
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps -- initial render only.
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
PerformanceMetricsManager.finishLoad('HOME', serverUrl);
|
PerformanceMetricsManager.finishLoad('HOME', serverUrl);
|
||||||
PerformanceMetricsManager.measureTimeToInteraction();
|
PerformanceMetricsManager.measureTimeToInteraction();
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps -- initial render only.
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -182,6 +183,7 @@ const ChannelListScreen = (props: ChannelProps) => {
|
||||||
edges={edges}
|
edges={edges}
|
||||||
testID='channel_list.screen'
|
testID='channel_list.screen'
|
||||||
>
|
>
|
||||||
|
<ConnectionBanner/>
|
||||||
{props.isLicensed &&
|
{props.isLicensed &&
|
||||||
<AnnouncementBanner/>
|
<AnnouncementBanner/>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue