Add playbooks unified view (#9177)
* Add playbooks unified view * Update tests * Fix client function not throwing * Fix types
This commit is contained in:
parent
6d136a1343
commit
9272efe2a8
32 changed files with 1233 additions and 62 deletions
|
|
@ -21,7 +21,7 @@ import {getCurrentUser, queryUsersById} from '@queries/servers/user';
|
|||
import {dismissAllModalsAndPopToRoot, dismissAllModalsAndPopToScreen} from '@screens/navigation';
|
||||
import EphemeralStore from '@store/ephemeral_store';
|
||||
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 type ServerDataOperator from '@database/operator/server_data_operator';
|
||||
|
|
@ -88,14 +88,16 @@ export async function switchToChannel(serverUrl: string, channelId: string, team
|
|||
}
|
||||
|
||||
if (isTabletDevice) {
|
||||
dismissAllModalsAndPopToRoot();
|
||||
await dismissAllModalsAndPopToRoot();
|
||||
DeviceEventEmitter.emit(NavigationConstants.NAVIGATION_HOME, Screens.CHANNEL);
|
||||
} 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');
|
||||
}
|
||||
} else {
|
||||
logDebug('failed to navigate to channel because there was no membership, channel id: ', channelId);
|
||||
}
|
||||
|
||||
return {models};
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ import {
|
|||
joinChannelIfNeeded,
|
||||
markChannelAsRead,
|
||||
unsetActiveChannelOnServer,
|
||||
switchToChannelByName,
|
||||
joinIfNeededAndSwitchToChannel,
|
||||
goToNPSChannel,
|
||||
fetchMissingDirectChannelsInfo,
|
||||
fetchDirectChannelsInfo,
|
||||
|
|
@ -434,14 +434,14 @@ describe('channel', () => {
|
|||
});
|
||||
|
||||
it('switchToChannelByName - handle not found database', async () => {
|
||||
const {error} = await switchToChannelByName('foo', '', '', () => {}, intl);
|
||||
const {error} = await joinIfNeededAndSwitchToChannel('foo', {}, {}, () => {}, intl);
|
||||
expect(error).toBeDefined();
|
||||
});
|
||||
|
||||
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});
|
||||
|
||||
const result = await switchToChannelByName(serverUrl, 'channelname', 'teamname', () => {}, intl);
|
||||
const result = await joinIfNeededAndSwitchToChannel(serverUrl, {name: 'channelname'}, {name: 'teamname'}, () => {}, intl);
|
||||
expect(result).toBeDefined();
|
||||
expect(result).not.toHaveProperty('error');
|
||||
});
|
||||
|
|
@ -449,7 +449,7 @@ describe('channel', () => {
|
|||
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});
|
||||
|
||||
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).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 {queryDisplayNamePreferences} from '@queries/servers/preference';
|
||||
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 {getCurrentUser} from '@queries/servers/user';
|
||||
import {dismissAllModalsAndPopToRoot} from '@screens/navigation';
|
||||
|
|
@ -38,7 +38,7 @@ import {fetchPostsForChannel} from './post';
|
|||
import {openChannelIfNeeded, savePreference} from './preference';
|
||||
import {fetchRolesIfNeeded} from './role';
|
||||
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 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) {
|
||||
const onError = (joinedTeam: boolean, teamId?: string) => {
|
||||
export async function joinIfNeededAndSwitchToChannel(
|
||||
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);
|
||||
if (joinedTeam && teamId) {
|
||||
removeCurrentUserFromTeam(serverUrl, teamId, false);
|
||||
if (joinedTeam && teamIdToRemove) {
|
||||
removeCurrentUserFromTeam(serverUrl, teamIdToRemove, false);
|
||||
}
|
||||
};
|
||||
|
||||
let joinedTeam = false;
|
||||
let teamId = '';
|
||||
let teamId = teamInfo?.id || '';
|
||||
let channelId = channelInfo?.id || '';
|
||||
const channelName = channelInfo?.name || '';
|
||||
const teamName = teamInfo?.name || '';
|
||||
|
||||
try {
|
||||
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
|
||||
|
|
@ -780,12 +789,14 @@ export async function switchToChannelByName(serverUrl: string, channelName: stri
|
|||
if (teamName === DeepLink.Redirect) {
|
||||
teamId = await getCurrentTeamId(database);
|
||||
} else {
|
||||
const team = await getTeamByName(database, teamName);
|
||||
const isTeamMember = team ? await getMyTeamById(database, team.id) : false;
|
||||
teamId = team?.id || '';
|
||||
const team = teamId ? await getTeamById(database, teamId) : await getTeamByName(database, teamName);
|
||||
const isTeamMember = team ? Boolean(await getMyTeamById(database, team.id)) : false;
|
||||
if (!teamId) {
|
||||
teamId = team?.id || '';
|
||||
}
|
||||
|
||||
if (!isTeamMember) {
|
||||
const fetchRequest = await fetchTeamByName(serverUrl, teamName);
|
||||
const fetchRequest = teamId ? await fetchTeamById(serverUrl, teamId) : await fetchTeamByName(serverUrl, teamName);
|
||||
if (!fetchRequest.team) {
|
||||
onError(joinedTeam);
|
||||
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;
|
||||
let channelId = channel?.id || '';
|
||||
if (!channelId) {
|
||||
channelId = channel?.id || '';
|
||||
}
|
||||
|
||||
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) {
|
||||
onError(joinedTeam, teamId);
|
||||
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);
|
||||
const joinRequest = await joinChannel(serverUrl, teamId, undefined, channelName, false);
|
||||
const joinRequest = await joinChannel(serverUrl, teamId, channelId, channelName, false);
|
||||
if (!joinRequest.channel) {
|
||||
onError(joinedTeam, teamId);
|
||||
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;
|
||||
}
|
||||
|
||||
switchToChannelById(serverUrl, channelId, teamId);
|
||||
await switchToChannelById(serverUrl, channelId, teamId);
|
||||
return {};
|
||||
} catch (error) {
|
||||
onError(joinedTeam, teamId);
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
sendEmailInvitesToTeam,
|
||||
fetchMyTeams,
|
||||
fetchMyTeam,
|
||||
fetchTeamById,
|
||||
fetchAllTeams,
|
||||
fetchTeamsForComponent,
|
||||
updateCanJoinTeams,
|
||||
|
|
@ -251,6 +252,14 @@ describe('teams', () => {
|
|||
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 () => {
|
||||
const result = await fetchAllTeams(serverUrl);
|
||||
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> {
|
||||
try {
|
||||
const client = NetworkManager.getClient(serverUrl);
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {getLastPlaybookRunsFetchAt} from '@playbooks/database/queries/run';
|
|||
import EphemeralStore from '@store/ephemeral_store';
|
||||
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 channelId = 'channel-id-1';
|
||||
|
|
@ -320,3 +320,98 @@ describe('finishRun', () => {
|
|||
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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -157,3 +157,23 @@ export const finishRun = async (serverUrl: string, playbookRunId: string) => {
|
|||
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 () => {
|
||||
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'));
|
||||
|
||||
const result = await client.fetchPlaybookRuns(params);
|
||||
|
||||
expect(result).toEqual(expectedResponse);
|
||||
expect(client.fetchPlaybookRuns(params)).rejects.toThrow('Network error');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -50,15 +50,11 @@ const ClientPlaybooks = <TBase extends Constructor<ClientBase>>(superclass: TBas
|
|||
fetchPlaybookRuns = async (params: FetchPlaybookRunsParams, groupLabel?: RequestGroupLabel) => {
|
||||
const queryParams = buildQueryString(params);
|
||||
|
||||
try {
|
||||
const data = await this.doFetch(
|
||||
`${this.getPlaybookRunsRoute()}${queryParams}`,
|
||||
{method: 'get', groupLabel},
|
||||
);
|
||||
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};
|
||||
}
|
||||
const data = await this.doFetch(
|
||||
`${this.getPlaybookRunsRoute()}${queryParams}`,
|
||||
{method: 'get', groupLabel},
|
||||
);
|
||||
return data || {items: [], total_count: 0, page_count: 0, has_more: false};
|
||||
};
|
||||
|
||||
fetchPlaybookRun = async (id: string, groupLabel?: RequestGroupLabel) => {
|
||||
|
|
|
|||
|
|
@ -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,6 +2,7 @@
|
|||
// See LICENSE.txt for license information.
|
||||
|
||||
export const PLAYBOOKS_RUNS = 'PlaybookRuns';
|
||||
export const PARTICIPANT_PLAYBOOKS = 'ParticipantPlaybooks';
|
||||
export const PLAYBOOK_RUN = 'PlaybookRun';
|
||||
export const PLAYBOOK_EDIT_COMMAND = 'PlaybookEditCommand';
|
||||
export const PLAYBOOK_POST_UPDATE = 'PlaybookPostUpdate';
|
||||
|
|
@ -10,6 +11,7 @@ export const PLAYBOOKS_SELECT_DATE = 'PlaybooksSelectDate';
|
|||
|
||||
export default {
|
||||
PLAYBOOKS_RUNS,
|
||||
PARTICIPANT_PLAYBOOKS,
|
||||
PLAYBOOK_RUN,
|
||||
PLAYBOOK_EDIT_COMMAND,
|
||||
PLAYBOOK_POST_UPDATE,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import TestHelper from '@test/test_helper';
|
|||
|
||||
import {
|
||||
queryPlaybookRunsPerChannel,
|
||||
queryPlaybookRunsByParticipant,
|
||||
getPlaybookRunById,
|
||||
observePlaybookRunById,
|
||||
observePlaybookRunProgress,
|
||||
|
|
@ -625,4 +626,85 @@ describe('Playbook Run Queries', () => {
|
|||
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,6 +8,7 @@ import Screens from '@playbooks/constants/screens';
|
|||
import {render} from '@test/intl-test-helper';
|
||||
|
||||
import EditCommand from './edit_command';
|
||||
import ParticipantPlaybooks from './participant_playbooks';
|
||||
import PlaybookRun from './playbook_run';
|
||||
import PlaybookRuns from './playbooks_runs';
|
||||
import PostUpdate from './post_update';
|
||||
|
|
@ -53,6 +54,12 @@ jest.mock('@playbooks/screens/select_date', () => ({
|
|||
}));
|
||||
jest.mocked(SelectDate).mockImplementation((props) => <Text {...props}>{Screens.PLAYBOOKS_SELECT_DATE}</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(),
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ export function loadPlaybooksScreen(screenName: string | number) {
|
|||
switch (screenName) {
|
||||
case Screens.PLAYBOOKS_RUNS:
|
||||
return withServerDatabase(require('@playbooks/screens/playbooks_runs').default);
|
||||
case Screens.PARTICIPANT_PLAYBOOKS:
|
||||
return withServerDatabase(require('@playbooks/screens/participant_playbooks').default);
|
||||
case Screens.PLAYBOOK_RUN:
|
||||
return withServerDatabase(require('@playbooks/screens/playbook_run').default);
|
||||
case Screens.PLAYBOOK_EDIT_COMMAND:
|
||||
|
|
|
|||
|
|
@ -1,18 +1,23 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {joinIfNeededAndSwitchToChannel} from '@actions/remote/channel';
|
||||
import {Preferences, Screens} from '@constants';
|
||||
import {goToScreen} from '@screens/navigation';
|
||||
import TestHelper from '@test/test_helper';
|
||||
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', () => ({
|
||||
goToScreen: jest.fn(),
|
||||
getThemeFromState: jest.fn(() => require('@constants').Preferences.THEMES.denim),
|
||||
}));
|
||||
|
||||
jest.mock('@actions/remote/channel', () => ({
|
||||
joinIfNeededAndSwitchToChannel: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('Playbooks Navigation', () => {
|
||||
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.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {joinIfNeededAndSwitchToChannel} from '@actions/remote/channel';
|
||||
import {Screens} from '@constants';
|
||||
import {getThemeFromState, goToScreen} from '@screens/navigation';
|
||||
import {errorBadChannel} from '@utils/draft';
|
||||
import {logDebug} from '@utils/log';
|
||||
import {changeOpacity} from '@utils/theme';
|
||||
|
||||
import type PlaybookRunModel from '@playbooks/types/database/models/playbook_run';
|
||||
import type {IntlShape} from 'react-intl';
|
||||
import type {Options as RNNOptions} from 'react-native-navigation';
|
||||
|
||||
|
|
@ -21,11 +25,32 @@ 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) {
|
||||
const title = intl.formatMessage({id: 'playbooks.playbook_run.title', defaultMessage: 'Playbook run'});
|
||||
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}, {});
|
||||
|
|
|
|||
|
|
@ -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,235 @@
|
|||
// 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 MenuDivider from '@components/menu_divider';
|
||||
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 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={MenuDivider}
|
||||
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;
|
||||
|
|
@ -7,7 +7,7 @@ import React, {type ComponentProps} from 'react';
|
|||
import UserChip from '@components/chips/user_chip';
|
||||
import UserAvatarsStack from '@components/user_avatars_stack';
|
||||
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 {renderWithIntl} from '@test/intl-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 {getByText} = renderWithIntl(<PlaybookCard {...props}/>);
|
||||
|
||||
|
|
@ -101,6 +101,24 @@ describe('PlaybookCard', () => {
|
|||
props.run.id,
|
||||
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', () => {
|
||||
|
|
|
|||
|
|
@ -9,9 +9,11 @@ import {CHIP_HEIGHT} from '@components/chips/constants';
|
|||
import UserChip from '@components/chips/user_chip';
|
||||
import FriendlyDate from '@components/friendly_date';
|
||||
import UserAvatarsStack from '@components/user_avatars_stack';
|
||||
import {useServerUrl} from '@context/server';
|
||||
import {useTheme} from '@context/theme';
|
||||
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 {openUserProfileModal} from '@screens/navigation';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
|
@ -89,13 +91,19 @@ const PlaybookCard = ({
|
|||
const lastUpdateAt = 'updateAt' in run ? run.updateAt : run.update_at;
|
||||
|
||||
const intl = useIntl();
|
||||
const serverUrl = useServerUrl();
|
||||
const theme = useTheme();
|
||||
const styles = getStyleFromTheme(theme);
|
||||
const finished = isRunFinished(run);
|
||||
|
||||
const onCardPress = useCallback(() => {
|
||||
goToPlaybookRun(intl, run.id, 'observe' in run ? undefined : run);
|
||||
}, [intl, run]);
|
||||
// If we're coming from the participant playbooks screen, we need to switch to the channel first
|
||||
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) => {
|
||||
openUserProfileModal(intl, theme, {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {CHANNEL, DRAFT, THREAD} from '@constants/screens';
|
|||
import {TABLET_SIDEBAR_WIDTH, TEAM_SIDEBAR_WIDTH} from '@constants/view';
|
||||
import {useTheme} from '@context/theme';
|
||||
import {useIsTablet} from '@hooks/device';
|
||||
import PlaybooksButton from '@playbooks/components/playbooks_button';
|
||||
import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
import Categories from './categories';
|
||||
|
|
@ -124,6 +125,12 @@ const CategoriesList = ({
|
|||
return null;
|
||||
}, [activeScreen, draftsCount, isTablet, scheduledPostCount, scheduledPostHasError, scheduledPostsEnabled]);
|
||||
|
||||
const playbooksButtonComponent = useMemo(() => {
|
||||
return (
|
||||
<PlaybooksButton/>
|
||||
);
|
||||
}, []);
|
||||
|
||||
const content = useMemo(() => {
|
||||
if (!hasChannels) {
|
||||
return (<LoadChannelsError/>);
|
||||
|
|
@ -134,10 +141,11 @@ const CategoriesList = ({
|
|||
<SubHeader/>
|
||||
{threadButtonComponent}
|
||||
{draftsButtonComponent}
|
||||
{playbooksButtonComponent}
|
||||
<Categories/>
|
||||
</>
|
||||
);
|
||||
}, [draftsButtonComponent, hasChannels, threadButtonComponent]);
|
||||
}, [draftsButtonComponent, hasChannels, playbooksButtonComponent, threadButtonComponent]);
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.container, tabletStyle]}>
|
||||
|
|
|
|||
|
|
@ -356,15 +356,15 @@ describe('dismissAllModalsAndPopToRoot', () => {
|
|||
});
|
||||
|
||||
it('should call dismissAllModals, dismissAllOverlaysWithExceptions, and popToRoot in sequence', async () => {
|
||||
NavigationStore.addModalToStack('modal1');
|
||||
NavigationStore.addScreenToStack('screen1');
|
||||
NavigationStore.addModalToStack('AppForm');
|
||||
NavigationStore.addScreenToStack('Home');
|
||||
|
||||
await navigationModule.dismissAllModalsAndPopToRoot();
|
||||
|
||||
expect(Navigation.dismissModal).toHaveBeenCalledTimes(1);
|
||||
expect(Navigation.dismissModal).toHaveBeenCalledWith('modal1', {animations: {dismissModal: {enabled: false}}});
|
||||
expect(Navigation.dismissModal).toHaveBeenCalledWith('AppForm', {animations: {dismissModal: {enabled: false}}});
|
||||
expect(Navigation.popToRoot).toHaveBeenCalledTimes(1);
|
||||
expect(Navigation.popToRoot).toHaveBeenCalledWith('screen1');
|
||||
expect(Navigation.popToRoot).toHaveBeenCalledWith('Home');
|
||||
});
|
||||
|
||||
it('should dismiss non-exception overlays via dismissAllOverlaysWithExceptions', async () => {
|
||||
|
|
@ -384,28 +384,28 @@ describe('dismissAllModalsAndPopToRoot', () => {
|
|||
|
||||
it('should handle empty overlay stack gracefully', async () => {
|
||||
expect(NavigationStore.getOverlaysInStack()).toEqual([]);
|
||||
NavigationStore.addScreenToStack('screen1');
|
||||
NavigationStore.addScreenToStack('Home');
|
||||
|
||||
await navigationModule.dismissAllModalsAndPopToRoot();
|
||||
|
||||
expect(Navigation.dismissModal).not.toHaveBeenCalled();
|
||||
expect(Navigation.dismissOverlay).not.toHaveBeenCalled();
|
||||
expect(Navigation.popToRoot).toHaveBeenCalledTimes(1);
|
||||
expect(Navigation.popToRoot).toHaveBeenCalledWith('screen1');
|
||||
expect(Navigation.popToRoot).toHaveBeenCalledWith('Home');
|
||||
});
|
||||
|
||||
it('should continue with popToRoot even if overlay dismissal fails', async () => {
|
||||
(Navigation.dismissOverlay as jest.Mock).mockRejectedValueOnce(new Error('Dismiss failed'));
|
||||
|
||||
NavigationStore.addOverlayToStack('overlay1');
|
||||
NavigationStore.addScreenToStack('screen1');
|
||||
NavigationStore.addScreenToStack('Home');
|
||||
|
||||
await expect(navigationModule.dismissAllModalsAndPopToRoot()).resolves.not.toThrow();
|
||||
|
||||
expect(Navigation.dismissModal).not.toHaveBeenCalled();
|
||||
expect(Navigation.dismissOverlay).toHaveBeenCalledWith('overlay1');
|
||||
expect(Navigation.popToRoot).toHaveBeenCalledTimes(1);
|
||||
expect(Navigation.popToRoot).toHaveBeenCalledWith('screen1');
|
||||
expect(Navigation.popToRoot).toHaveBeenCalledWith('Home');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -592,13 +592,13 @@ export async function dismissAllModalsAndPopToScreen(screenId: AvailableScreens,
|
|||
try {
|
||||
await Navigation.popTo(screenId, mergeOptions);
|
||||
if (Object.keys(passProps).length > 0) {
|
||||
await Navigation.updateProps(screenId, passProps);
|
||||
Navigation.updateProps(screenId, passProps);
|
||||
}
|
||||
} catch {
|
||||
// catch in case there is nothing to pop
|
||||
}
|
||||
} else {
|
||||
goToScreen(screenId, title, passProps, options);
|
||||
await goToScreen(screenId, title, passProps, options);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ const mockGoToScreen = goToScreen as jest.Mock;
|
|||
|
||||
describe('AdvancedSettings', () => {
|
||||
const defaultProps = {
|
||||
componentId: 'advanced-settings-screen' as const,
|
||||
componentId: 'SettingsAdvanced' as const,
|
||||
isDevMode: false,
|
||||
lowConnectivityMonitorEnabled: false,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import {createIntl} from 'react-intl';
|
|||
import {Alert} from 'react-native';
|
||||
import {Navigation} from 'react-native-navigation';
|
||||
|
||||
import {makeDirectChannel, switchToChannelByName} from '@actions/remote/channel';
|
||||
import {joinIfNeededAndSwitchToChannel, makeDirectChannel} from '@actions/remote/channel';
|
||||
import {showPermalink} from '@actions/remote/permalink';
|
||||
import {fetchUsersByUsernames} from '@actions/remote/user';
|
||||
import {DeepLink, Launch, Preferences, Screens} from '@constants';
|
||||
|
|
@ -67,7 +67,7 @@ jest.mock('@utils/server', () => ({
|
|||
|
||||
jest.mock('@actions/remote/channel', () => ({
|
||||
makeDirectChannel: jest.fn(),
|
||||
switchToChannelByName: jest.fn(),
|
||||
joinIfNeededAndSwitchToChannel: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@utils/draft', () => ({
|
||||
|
|
@ -167,7 +167,7 @@ describe('parseAndHandleDeepLink', () => {
|
|||
jest.mocked(DatabaseManager.searchUrl).mockReturnValueOnce('https://existingserver.com');
|
||||
jest.mocked(getActiveServerUrl).mockResolvedValueOnce('https://existingserver.com');
|
||||
const result = await parseAndHandleDeepLink('https://existingserver.com/team/channels/town-square', intl);
|
||||
expect(switchToChannelByName).toHaveBeenCalledWith('https://existingserver.com', 'town-square', 'team', errorBadChannel, intl);
|
||||
expect(joinIfNeededAndSwitchToChannel).toHaveBeenCalledWith('https://existingserver.com', {name: 'town-square'}, {name: 'team'}, errorBadChannel, intl);
|
||||
expect(result).toEqual({error: false});
|
||||
});
|
||||
|
||||
|
|
@ -204,7 +204,7 @@ describe('parseAndHandleDeepLink', () => {
|
|||
jest.mocked(DatabaseManager.searchUrl).mockReturnValueOnce('https://existingserver.com');
|
||||
jest.mocked(getActiveServerUrl).mockResolvedValueOnce('https://existingserver.com');
|
||||
const result = await parseAndHandleDeepLink('https://existingserver.com/team/messages/7b35c77a645e1906e03a2c330f89203385db102f', intl);
|
||||
expect(switchToChannelByName).toHaveBeenCalledWith('https://existingserver.com', '7b35c77a645e1906e03a2c330f89203385db102f', 'team', errorBadChannel, intl);
|
||||
expect(joinIfNeededAndSwitchToChannel).toHaveBeenCalledWith('https://existingserver.com', {name: '7b35c77a645e1906e03a2c330f89203385db102f'}, {name: 'team'}, errorBadChannel, intl);
|
||||
expect(result).toEqual({error: false});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {Alert} from 'react-native';
|
|||
import {Navigation} from 'react-native-navigation';
|
||||
import urlParse from 'url-parse';
|
||||
|
||||
import {makeDirectChannel, switchToChannelByName} from '@actions/remote/channel';
|
||||
import {joinIfNeededAndSwitchToChannel, makeDirectChannel} from '@actions/remote/channel';
|
||||
import {showPermalink} from '@actions/remote/permalink';
|
||||
import {fetchUsersByUsernames} from '@actions/remote/user';
|
||||
import {DeepLink, Launch, Screens} from '@constants';
|
||||
|
|
@ -77,7 +77,7 @@ export async function handleDeepLink(deepLink: DeepLinkWithData, intlShape?: Int
|
|||
switch (deepLink.type) {
|
||||
case DeepLink.Channel: {
|
||||
const deepLinkData = deepLink.data as DeepLinkChannel;
|
||||
switchToChannelByName(existingServerUrl, deepLinkData.channelName, deepLinkData.teamName, errorBadChannel, intl);
|
||||
joinIfNeededAndSwitchToChannel(existingServerUrl, {name: deepLinkData.channelName}, {name: deepLinkData.teamName}, errorBadChannel, intl);
|
||||
break;
|
||||
}
|
||||
case DeepLink.DirectMessage: {
|
||||
|
|
@ -100,7 +100,7 @@ export async function handleDeepLink(deepLink: DeepLinkWithData, intlShape?: Int
|
|||
}
|
||||
case DeepLink.GroupMessage: {
|
||||
const deepLinkData = deepLink.data as DeepLinkGM;
|
||||
switchToChannelByName(existingServerUrl, deepLinkData.channelName, deepLinkData.teamName, errorBadChannel, intl);
|
||||
joinIfNeededAndSwitchToChannel(existingServerUrl, {name: deepLinkData.channelName}, {name: deepLinkData.teamName}, errorBadChannel, intl);
|
||||
break;
|
||||
}
|
||||
case DeepLink.Permalink: {
|
||||
|
|
|
|||
|
|
@ -1015,12 +1015,18 @@
|
|||
"playbooks.fetch_error.description": "You don't have permission to view this run, or it may no longer exist.",
|
||||
"playbooks.fetch_error.OK": "Okay",
|
||||
"playbooks.fetch_error.title": "Unable to open Run",
|
||||
"playbooks.home_button.title": "Playbook runs",
|
||||
"playbooks.not_enabled_or_unsupported.description": "Playbooks are either not enabled on this server or the Playbooks version is not supported. Please contact your system administrator.",
|
||||
"playbooks.not_enabled_or_unsupported.OK": "OK",
|
||||
"playbooks.not_enabled_or_unsupported.title": "Playbooks not available",
|
||||
"playbooks.only_runs_available.description": "Only Playbook Runs are available on mobile. To access the Playbook, please use the desktop or web app.",
|
||||
"playbooks.only_runs_available.ok": "OK",
|
||||
"playbooks.only_runs_available.title": "Playbooks not available",
|
||||
"playbooks.participant_playbooks.cached_warning_message": "Showing cached data only. Some playbook runs or updates may be missing from this list.",
|
||||
"playbooks.participant_playbooks.cached_warning_title": "Cannot reach the server",
|
||||
"playbooks.participant_playbooks.tab_finished": "Finished",
|
||||
"playbooks.participant_playbooks.tab_in_progress": "In Progress",
|
||||
"playbooks.participant_playbooks.title": "Playbook runs",
|
||||
"playbooks.playbook_run.error.description": "Please check your network connection or try again later.",
|
||||
"playbooks.playbook_run.error.title": "Unable to fetch run details",
|
||||
"playbooks.playbook_run.finish_run_button": "Finish Run",
|
||||
|
|
|
|||
Loading…
Reference in a new issue