From 9272efe2a84b4ced4644693a38fecfb31abf7a0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Espino=20Garc=C3=ADa?= Date: Mon, 13 Oct 2025 21:02:24 +0200 Subject: [PATCH] Add playbooks unified view (#9177) * Add playbooks unified view * Update tests * Fix client function not throwing * Fix types --- app/actions/local/channel.ts | 8 +- app/actions/remote/channel.test.ts | 8 +- app/actions/remote/channel.ts | 46 ++-- app/actions/remote/team.test.ts | 9 + app/actions/remote/team.ts | 12 + .../playbooks/actions/remote/runs.test.ts | 97 ++++++- app/products/playbooks/actions/remote/runs.ts | 20 ++ app/products/playbooks/client/rest.test.ts | 5 +- app/products/playbooks/client/rest.ts | 14 +- .../components/playbooks_button/index.ts | 6 + .../playbooks_button.test.tsx | 40 +++ .../playbooks_button/playbooks_button.tsx | 86 +++++++ app/products/playbooks/constants/screens.ts | 2 + .../playbooks/database/queries/run.test.ts | 82 ++++++ .../playbooks/database/queries/run.ts | 7 + app/products/playbooks/screens/index.test.tsx | 7 + app/products/playbooks/screens/index.tsx | 2 + .../playbooks/screens/navigation.test.ts | 58 ++++- app/products/playbooks/screens/navigation.ts | 25 ++ .../participant_playbooks/index.test.tsx | 171 +++++++++++++ .../screens/participant_playbooks/index.ts | 30 +++ .../participant_playbooks.test.tsx | 237 ++++++++++++++++++ .../participant_playbooks.tsx | 235 +++++++++++++++++ .../playbook_card/playbook_card.test.tsx | 22 +- .../playbook_card/playbook_card.tsx | 14 +- .../categories_list/categories_list.tsx | 10 +- app/screens/navigation.test.ts | 16 +- app/screens/navigation.ts | 4 +- .../settings/advanced/advanced.test.tsx | 2 +- app/utils/deep_link/index.test.ts | 8 +- app/utils/deep_link/index.ts | 6 +- assets/base/i18n/en.json | 6 + 32 files changed, 1233 insertions(+), 62 deletions(-) create mode 100644 app/products/playbooks/components/playbooks_button/index.ts create mode 100644 app/products/playbooks/components/playbooks_button/playbooks_button.test.tsx create mode 100644 app/products/playbooks/components/playbooks_button/playbooks_button.tsx create mode 100644 app/products/playbooks/screens/participant_playbooks/index.test.tsx create mode 100644 app/products/playbooks/screens/participant_playbooks/index.ts create mode 100644 app/products/playbooks/screens/participant_playbooks/participant_playbooks.test.tsx create mode 100644 app/products/playbooks/screens/participant_playbooks/participant_playbooks.tsx diff --git a/app/actions/local/channel.ts b/app/actions/local/channel.ts index 326c95289..7d2db49a7 100644 --- a/app/actions/local/channel.ts +++ b/app/actions/local/channel.ts @@ -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}; diff --git a/app/actions/remote/channel.test.ts b/app/actions/remote/channel.test.ts index cf78388e4..c686af023 100644 --- a/app/actions/remote/channel.test.ts +++ b/app/actions/remote/channel.test.ts @@ -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'); }); diff --git a/app/actions/remote/channel.ts b/app/actions/remote/channel.ts index 08737319f..1b6ecd756 100644 --- a/app/actions/remote/channel.ts +++ b/app/actions/remote/channel.ts @@ -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); diff --git a/app/actions/remote/team.test.ts b/app/actions/remote/team.test.ts index 43b878db1..89770a117 100644 --- a/app/actions/remote/team.test.ts +++ b/app/actions/remote/team.test.ts @@ -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(); diff --git a/app/actions/remote/team.ts b/app/actions/remote/team.ts index 0747101ff..f79e3869c 100644 --- a/app/actions/remote/team.ts +++ b/app/actions/remote/team.ts @@ -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 { try { const client = NetworkManager.getClient(serverUrl); diff --git a/app/products/playbooks/actions/remote/runs.test.ts b/app/products/playbooks/actions/remote/runs.test.ts index 20804a344..d779fd4e2 100644 --- a/app/products/playbooks/actions/remote/runs.test.ts +++ b/app/products/playbooks/actions/remote/runs.test.ts @@ -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', + }); + }); +}); diff --git a/app/products/playbooks/actions/remote/runs.ts b/app/products/playbooks/actions/remote/runs.ts index fc100438a..ecff9f252 100644 --- a/app/products/playbooks/actions/remote/runs.ts +++ b/app/products/playbooks/actions/remote/runs.ts @@ -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}; + } +}; diff --git a/app/products/playbooks/client/rest.test.ts b/app/products/playbooks/client/rest.test.ts index 31725ba17..bd6864b5c 100644 --- a/app/products/playbooks/client/rest.test.ts +++ b/app/products/playbooks/client/rest.test.ts @@ -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'); }); }); diff --git a/app/products/playbooks/client/rest.ts b/app/products/playbooks/client/rest.ts index 9f7e90a3f..c923a0446 100644 --- a/app/products/playbooks/client/rest.ts +++ b/app/products/playbooks/client/rest.ts @@ -50,15 +50,11 @@ const ClientPlaybooks = >(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) => { diff --git a/app/products/playbooks/components/playbooks_button/index.ts b/app/products/playbooks/components/playbooks_button/index.ts new file mode 100644 index 000000000..6dad22ed4 --- /dev/null +++ b/app/products/playbooks/components/playbooks_button/index.ts @@ -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; diff --git a/app/products/playbooks/components/playbooks_button/playbooks_button.test.tsx b/app/products/playbooks/components/playbooks_button/playbooks_button.test.tsx new file mode 100644 index 000000000..a90155a43 --- /dev/null +++ b/app/products/playbooks/components/playbooks_button/playbooks_button.test.tsx @@ -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(); + + 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(); + + const button = getByTestId('channel_list.playbooks.button'); + fireEvent.press(button); + + expect(goToParticipantPlaybooks).toHaveBeenCalled(); + }); +}); diff --git a/app/products/playbooks/components/playbooks_button/playbooks_button.tsx b/app/products/playbooks/components/playbooks_button/playbooks_button.tsx new file mode 100644 index 000000000..50647828d --- /dev/null +++ b/app/products/playbooks/components/playbooks_button/playbooks_button.tsx @@ -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 ( + + + + + + + ); +}; + +export default React.memo(PlaybooksButton); diff --git a/app/products/playbooks/constants/screens.ts b/app/products/playbooks/constants/screens.ts index 2f5c81d86..912d16dbd 100644 --- a/app/products/playbooks/constants/screens.ts +++ b/app/products/playbooks/constants/screens.ts @@ -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, diff --git a/app/products/playbooks/database/queries/run.test.ts b/app/products/playbooks/database/queries/run.test.ts index 44dc07cd3..352d7a1fd 100644 --- a/app/products/playbooks/database/queries/run.test.ts +++ b/app/products/playbooks/database/queries/run.test.ts @@ -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); + }); + }); }); diff --git a/app/products/playbooks/database/queries/run.ts b/app/products/playbooks/database/queries/run.ts index c65ed6968..50397423e 100644 --- a/app/products/playbooks/database/queries/run.ts +++ b/app/products/playbooks/database/queries/run.ts @@ -88,3 +88,10 @@ export const observeParticipantsIdsFromPlaybookModel = (runModel: PlaybookRunMod }), ); }; + +export const queryPlaybookRunsByParticipant = (database: Database, participantId: string) => { + return database.get(PLAYBOOK_RUN).query( + Q.where('participant_ids', Q.like(`%"${participantId}"%`)), + Q.sortBy('create_at', 'desc'), + ); +}; diff --git a/app/products/playbooks/screens/index.test.tsx b/app/products/playbooks/screens/index.test.tsx index bdef64703..09e2a967e 100644 --- a/app/products/playbooks/screens/index.test.tsx +++ b/app/products/playbooks/screens/index.test.tsx @@ -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) => {Screens.PLAYBOOKS_SELECT_DATE}); +jest.mock('@playbooks/screens/participant_playbooks', () => ({ + __esModule: true, + default: jest.fn(), +})); +jest.mocked(ParticipantPlaybooks).mockImplementation((props) => {Screens.PARTICIPANT_PLAYBOOKS}); + jest.mock('@playbooks/screens/post_update', () => ({ __esModule: true, default: jest.fn(), diff --git a/app/products/playbooks/screens/index.tsx b/app/products/playbooks/screens/index.tsx index ec387c75d..e36dc4cc9 100644 --- a/app/products/playbooks/screens/index.tsx +++ b/app/products/playbooks/screens/index.tsx @@ -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: diff --git a/app/products/playbooks/screens/navigation.test.ts b/app/products/playbooks/screens/navigation.test.ts index eb45b6980..27958d0b1 100644 --- a/app/products/playbooks/screens/navigation.test.ts +++ b/app/products/playbooks/screens/navigation.test.ts @@ -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}, + {}, + ); + }); + }); }); diff --git a/app/products/playbooks/screens/navigation.ts b/app/products/playbooks/screens/navigation.ts index 3e5a20ea1..5ed1277a6 100644 --- a/app/products/playbooks/screens/navigation.ts +++ b/app/products/playbooks/screens/navigation.ts @@ -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}, {}); diff --git a/app/products/playbooks/screens/participant_playbooks/index.test.tsx b/app/products/playbooks/screens/participant_playbooks/index.test.tsx new file mode 100644 index 000000000..7cb5991bf --- /dev/null +++ b/app/products/playbooks/screens/participant_playbooks/index.test.tsx @@ -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( + , + {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( + , + {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( + , + {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); + }); + }); +}); diff --git a/app/products/playbooks/screens/participant_playbooks/index.ts b/app/products/playbooks/screens/participant_playbooks/index.ts new file mode 100644 index 000000000..5fc0d947a --- /dev/null +++ b/app/products/playbooks/screens/participant_playbooks/index.ts @@ -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)); diff --git a/app/products/playbooks/screens/participant_playbooks/participant_playbooks.test.tsx b/app/products/playbooks/screens/participant_playbooks/participant_playbooks.test.tsx new file mode 100644 index 000000000..49c6ece6c --- /dev/null +++ b/app/products/playbooks/screens/participant_playbooks/participant_playbooks.test.tsx @@ -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>; + +describe('ParticipantPlaybooks', () => { + function getBaseProps(): ComponentProps { + 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((resolve) => { + resolvePromise = resolve; + }); + jest.mocked(fetchPlaybookRunsPageForParticipant).mockReturnValue(pendingPromise); + + const {getByTestId, queryByTestId} = renderWithEverything(, {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(, {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(, {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(, {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(, {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(, {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(, {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(, {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(, {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(, {database, serverUrl}); + + await waitForElementToBeRemoved(() => queryByTestId('loading')); + + expect(queryByText(/Showing cached data only/)).not.toBeVisible(); + }); +}); diff --git a/app/products/playbooks/screens/participant_playbooks/participant_playbooks.tsx b/app/products/playbooks/screens/participant_playbooks/participant_playbooks.tsx new file mode 100644 index 000000000..078e018d9 --- /dev/null +++ b/app/products/playbooks/screens/participant_playbooks/participant_playbooks.tsx @@ -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> = [ + { + 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>([]); + 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 = []; + const finished: Array = []; + + participantRuns.forEach((run) => { + if (isRunFinished(run)) { + finished.push(run); + } else { + inProgress.push(run); + } + }); + + return [inProgress, finished] as const; + }, [participantRuns]); + + const [activeTab, tabsProps] = useTabs('in-progress', tabs); + + const data = activeTab === 'in-progress' ? inProgressRuns : finishedRuns; + const isEmpty = data.length === 0; + + const renderItem: ListRenderItem = useCallback(({item}) => { + return ( + + ); + }, []); + + let content; + if (loading) { + content = ; + } else if (hasError || isEmpty) { + content = (); + } else { + content = ( + : undefined} + testID={'runs.list'} + /> + ); + } + + return ( + <> + + + + {showCachedWarning && ( + + + + )} + {content} + + ); +}; + +export default ParticipantPlaybooks; diff --git a/app/products/playbooks/screens/playbooks_runs/playbook_card/playbook_card.test.tsx b/app/products/playbooks/screens/playbooks_runs/playbook_card/playbook_card.test.tsx index b796fdc2f..b972f2307 100644 --- a/app/products/playbooks/screens/playbooks_runs/playbook_card/playbook_card.test.tsx +++ b/app/products/playbooks/screens/playbooks_runs/playbook_card/playbook_card.test.tsx @@ -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(); @@ -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(); + + 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', () => { diff --git a/app/products/playbooks/screens/playbooks_runs/playbook_card/playbook_card.tsx b/app/products/playbooks/screens/playbooks_runs/playbook_card/playbook_card.tsx index b0147204a..13ed96020 100644 --- a/app/products/playbooks/screens/playbooks_runs/playbook_card/playbook_card.tsx +++ b/app/products/playbooks/screens/playbooks_runs/playbook_card/playbook_card.tsx @@ -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, { diff --git a/app/screens/home/channel_list/categories_list/categories_list.tsx b/app/screens/home/channel_list/categories_list/categories_list.tsx index 4652ec1f9..121c1e859 100644 --- a/app/screens/home/channel_list/categories_list/categories_list.tsx +++ b/app/screens/home/channel_list/categories_list/categories_list.tsx @@ -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 ( + + ); + }, []); + const content = useMemo(() => { if (!hasChannels) { return (); @@ -134,10 +141,11 @@ const CategoriesList = ({ {threadButtonComponent} {draftsButtonComponent} + {playbooksButtonComponent} ); - }, [draftsButtonComponent, hasChannels, threadButtonComponent]); + }, [draftsButtonComponent, hasChannels, playbooksButtonComponent, threadButtonComponent]); return ( diff --git a/app/screens/navigation.test.ts b/app/screens/navigation.test.ts index 1f128f108..7e0c4a361 100644 --- a/app/screens/navigation.test.ts +++ b/app/screens/navigation.test.ts @@ -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'); }); }); diff --git a/app/screens/navigation.ts b/app/screens/navigation.ts index 5311d8918..d8fffaf58 100644 --- a/app/screens/navigation.ts +++ b/app/screens/navigation.ts @@ -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); } } diff --git a/app/screens/settings/advanced/advanced.test.tsx b/app/screens/settings/advanced/advanced.test.tsx index 17ab0c7a9..401bc869d 100644 --- a/app/screens/settings/advanced/advanced.test.tsx +++ b/app/screens/settings/advanced/advanced.test.tsx @@ -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, }; diff --git a/app/utils/deep_link/index.test.ts b/app/utils/deep_link/index.test.ts index 5c1cc4b16..b686b76a9 100644 --- a/app/utils/deep_link/index.test.ts +++ b/app/utils/deep_link/index.test.ts @@ -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}); }); diff --git a/app/utils/deep_link/index.ts b/app/utils/deep_link/index.ts index d932f3211..70b19ca21 100644 --- a/app/utils/deep_link/index.ts +++ b/app/utils/deep_link/index.ts @@ -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: { diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 6c935ac8e..db7ce82a9 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -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",