Add playbooks unified view (#9177)

* Add playbooks unified view

* Update tests

* Fix client function not throwing

* Fix types
This commit is contained in:
Daniel Espino García 2025-10-13 21:02:24 +02:00 committed by GitHub
parent 6d136a1343
commit 9272efe2a8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 1233 additions and 62 deletions

View file

@ -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};

View file

@ -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');
});

View file

@ -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);

View file

@ -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();

View file

@ -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);

View file

@ -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',
});
});
});

View file

@ -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};
}
};

View file

@ -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');
});
});

View file

@ -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) => {

View file

@ -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;

View file

@ -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();
});
});

View file

@ -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);

View file

@ -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,

View file

@ -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);
});
});
});

View file

@ -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'),
);
};

View file

@ -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(),

View file

@ -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:

View file

@ -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},
{},
);
});
});
});

View file

@ -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}, {});

View file

@ -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);
});
});
});

View file

@ -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));

View file

@ -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();
});
});

View file

@ -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;

View file

@ -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', () => {

View file

@ -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, {

View file

@ -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]}>

View file

@ -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');
});
});

View file

@ -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);
}
}

View file

@ -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,
};

View file

@ -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});
});

View file

@ -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: {

View file

@ -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",