diff --git a/app/products/playbooks/actions/remote/runs.test.ts b/app/products/playbooks/actions/remote/runs.test.ts index d84b8bc2e..20804a344 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} from './runs'; +import {fetchPlaybookRunsForChannel, fetchFinishedRunsForChannel, setOwner, finishRun} from './runs'; const serverUrl = 'baseHandler.test.com'; const channelId = 'channel-id-1'; @@ -21,6 +21,7 @@ const mockPlaybookRun2 = TestHelper.fakePlaybookRun({channel_id: channelId}); const mockClient = { fetchPlaybookRuns: jest.fn(), setOwner: jest.fn(), + finishRun: jest.fn(), }; jest.mock('@playbooks/database/queries/run'); @@ -271,3 +272,51 @@ describe('setOwner', () => { expect(localSetOwner).toHaveBeenCalledWith(serverUrl, '', ''); }); }); + +describe('finishRun', () => { + const playbookRunId = 'run-123'; + + it('should finish run successfully', async () => { + mockClient.finishRun = jest.fn().mockResolvedValueOnce(undefined); + + const result = await finishRun(serverUrl, playbookRunId); + + expect(result).toBeDefined(); + expect(result.error).toBeUndefined(); + expect(result.data).toBe(true); + expect(mockClient.finishRun).toHaveBeenCalledWith(playbookRunId); + }); + + it('should handle client error', async () => { + const clientError = new Error('Client error'); + mockClient.finishRun = jest.fn().mockRejectedValueOnce(clientError); + + const result = await finishRun(serverUrl, playbookRunId); + + expect(result).toBeDefined(); + expect(result.error).toBeDefined(); + expect(result.data).toBeUndefined(); + expect(mockClient.finishRun).toHaveBeenCalledWith(playbookRunId); + }); + + it('should handle network manager error', async () => { + jest.spyOn(NetworkManager, 'getClient').mockImplementationOnce(throwFunc); + + const result = await finishRun(serverUrl, playbookRunId); + + expect(result).toBeDefined(); + expect(result.error).toBeDefined(); + expect(result.data).toBeUndefined(); + }); + + it('should handle empty string parameter', async () => { + mockClient.finishRun = jest.fn().mockResolvedValueOnce(undefined); + + const result = await finishRun(serverUrl, ''); + + expect(result).toBeDefined(); + expect(result.error).toBeUndefined(); + expect(result.data).toBe(true); + expect(mockClient.finishRun).toHaveBeenCalledWith(''); + }); +}); diff --git a/app/products/playbooks/actions/remote/runs.ts b/app/products/playbooks/actions/remote/runs.ts index ec466f49f..6e6708ef5 100644 --- a/app/products/playbooks/actions/remote/runs.ts +++ b/app/products/playbooks/actions/remote/runs.ts @@ -123,3 +123,15 @@ export const setOwner = async (serverUrl: string, playbookRunId: string, ownerId return {error}; } }; + +export const finishRun = async (serverUrl: string, playbookRunId: string) => { + try { + const client = NetworkManager.getClient(serverUrl); + await client.finishRun(playbookRunId); + return {data: true}; + } catch (error) { + logDebug('error on finishRun', getFullErrorMessage(error)); + forceLogoutIfNecessary(serverUrl, error); + return {error}; + } +}; diff --git a/app/products/playbooks/actions/websocket/reconnect.test.ts b/app/products/playbooks/actions/websocket/reconnect.test.ts index 19fd192f5..39fad9270 100644 --- a/app/products/playbooks/actions/websocket/reconnect.test.ts +++ b/app/products/playbooks/actions/websocket/reconnect.test.ts @@ -9,6 +9,7 @@ import {fetchIsPlaybooksEnabled} from '@playbooks/database/queries/version'; import {getCurrentChannelId} from '@queries/servers/system'; import EphemeralStore from '@store/ephemeral_store'; import NavigationStore from '@store/navigation_store'; +import {isTablet} from '@utils/helpers'; import {handlePlaybookReconnect} from './reconnect'; @@ -18,6 +19,7 @@ jest.mock('@playbooks/actions/remote/runs'); jest.mock('@playbooks/actions/remote/version'); jest.mock('@playbooks/database/queries/version'); jest.mock('@queries/servers/system'); +jest.mock('@utils/helpers'); describe('handlePlaybookReconnect', () => { const mockCurrentChannelId = 'channel-123'; @@ -31,6 +33,7 @@ describe('handlePlaybookReconnect', () => { jest.mocked(fetchIsPlaybooksEnabled).mockResolvedValue(false); jest.mocked(getCurrentChannelId).mockResolvedValue(mockCurrentChannelId); jest.mocked(fetchPlaybookRunsForChannel).mockResolvedValue({runs: []}); + jest.mocked(isTablet).mockReturnValue(false); }); afterEach(async () => { @@ -65,39 +68,84 @@ describe('handlePlaybookReconnect', () => { expect(updatePlaybooksVersion).toHaveBeenCalledTimes(1); }); - it('should not fetch playbook runs when not on channel screen', async () => { - const getScreensSpy = jest.spyOn(NavigationStore, 'getScreensInStack').mockReturnValue([Screens.HOME, Screens.THREAD]); + describe('on phone device', () => { + it('should not fetch playbook runs when not on channel screen', async () => { + const getScreensSpy = jest.spyOn(NavigationStore, 'getScreensInStack').mockReturnValue([Screens.HOME, Screens.THREAD]); - await handlePlaybookReconnect(serverUrl); + await handlePlaybookReconnect(serverUrl); - expect(getScreensSpy).toHaveBeenCalled(); - expect(fetchIsPlaybooksEnabled).not.toHaveBeenCalled(); - expect(getCurrentChannelId).not.toHaveBeenCalled(); - expect(fetchPlaybookRunsForChannel).not.toHaveBeenCalled(); + expect(getScreensSpy).toHaveBeenCalled(); + expect(fetchIsPlaybooksEnabled).not.toHaveBeenCalled(); + expect(getCurrentChannelId).not.toHaveBeenCalled(); + expect(fetchPlaybookRunsForChannel).not.toHaveBeenCalled(); + }); + + it('should not fetch playbook runs when playbooks are disabled', async () => { + const getScreensSpy = jest.spyOn(NavigationStore, 'getScreensInStack').mockReturnValue([Screens.HOME, Screens.CHANNEL, Screens.THREAD]); + jest.mocked(fetchIsPlaybooksEnabled).mockResolvedValue(false); + + await handlePlaybookReconnect(serverUrl); + + expect(getScreensSpy).toHaveBeenCalled(); + expect(fetchIsPlaybooksEnabled).toHaveBeenCalled(); + expect(getCurrentChannelId).not.toHaveBeenCalled(); + expect(fetchPlaybookRunsForChannel).not.toHaveBeenCalled(); + }); + + it('should fetch playbook runs when on channel screen and playbooks are enabled', async () => { + const getScreensSpy = jest.spyOn(NavigationStore, 'getScreensInStack').mockReturnValue([Screens.CHANNEL]); + jest.mocked(fetchIsPlaybooksEnabled).mockResolvedValue(true); + + await handlePlaybookReconnect(serverUrl); + + expect(getScreensSpy).toHaveBeenCalled(); + expect(fetchIsPlaybooksEnabled).toHaveBeenCalled(); + expect(getCurrentChannelId).toHaveBeenCalled(); + expect(fetchPlaybookRunsForChannel).toHaveBeenCalledWith(serverUrl, mockCurrentChannelId); + expect(fetchPlaybookRunsForChannel).toHaveBeenCalledTimes(1); + }); }); - it('should not fetch playbook runs when playbooks are disabled', async () => { - const getScreensSpy = jest.spyOn(NavigationStore, 'getScreensInStack').mockReturnValue([Screens.HOME, Screens.CHANNEL, Screens.THREAD]); - jest.mocked(fetchIsPlaybooksEnabled).mockResolvedValue(false); + describe('on tablet device', () => { + it('should fetch playbook runs when playbooks are enabled and there is a channel id', async () => { + const getScreensSpy = jest.spyOn(NavigationStore, 'getScreensInStack').mockReturnValue([Screens.HOME]); + jest.mocked(fetchIsPlaybooksEnabled).mockResolvedValue(true); + jest.mocked(isTablet).mockReturnValueOnce(true); - await handlePlaybookReconnect(serverUrl); + await handlePlaybookReconnect(serverUrl); - expect(getScreensSpy).toHaveBeenCalled(); - expect(fetchIsPlaybooksEnabled).toHaveBeenCalled(); - expect(getCurrentChannelId).not.toHaveBeenCalled(); - expect(fetchPlaybookRunsForChannel).not.toHaveBeenCalled(); - }); + expect(getScreensSpy).toHaveBeenCalled(); + expect(fetchIsPlaybooksEnabled).toHaveBeenCalled(); + expect(getCurrentChannelId).toHaveBeenCalled(); + expect(fetchPlaybookRunsForChannel).toHaveBeenCalledWith(serverUrl, mockCurrentChannelId); + expect(fetchPlaybookRunsForChannel).toHaveBeenCalledTimes(1); + }); - it('should fetch playbook runs when on channel screen and playbooks are enabled', async () => { - const getScreensSpy = jest.spyOn(NavigationStore, 'getScreensInStack').mockReturnValue([Screens.CHANNEL]); - jest.mocked(fetchIsPlaybooksEnabled).mockResolvedValue(true); + it('should not fetch playbook runs when playbooks are enabled and there is no channel id', async () => { + const getScreensSpy = jest.spyOn(NavigationStore, 'getScreensInStack').mockReturnValue([Screens.HOME]); + jest.mocked(fetchIsPlaybooksEnabled).mockResolvedValue(true); + jest.mocked(isTablet).mockReturnValueOnce(true); + jest.mocked(getCurrentChannelId).mockResolvedValue(''); - await handlePlaybookReconnect(serverUrl); + await handlePlaybookReconnect(serverUrl); - expect(getScreensSpy).toHaveBeenCalled(); - expect(fetchIsPlaybooksEnabled).toHaveBeenCalled(); - expect(getCurrentChannelId).toHaveBeenCalled(); - expect(fetchPlaybookRunsForChannel).toHaveBeenCalledWith(serverUrl, mockCurrentChannelId); - expect(fetchPlaybookRunsForChannel).toHaveBeenCalledTimes(1); + expect(getScreensSpy).toHaveBeenCalled(); + expect(fetchIsPlaybooksEnabled).toHaveBeenCalled(); + expect(getCurrentChannelId).toHaveBeenCalled(); + expect(fetchPlaybookRunsForChannel).not.toHaveBeenCalled(); + }); + + it('should not fetch playbook runs when playbooks are disabled', async () => { + const getScreensSpy = jest.spyOn(NavigationStore, 'getScreensInStack').mockReturnValue([Screens.HOME]); + jest.mocked(fetchIsPlaybooksEnabled).mockResolvedValue(false); + jest.mocked(isTablet).mockReturnValueOnce(true); + + await handlePlaybookReconnect(serverUrl); + + expect(getScreensSpy).toHaveBeenCalled(); + expect(fetchIsPlaybooksEnabled).toHaveBeenCalled(); + expect(getCurrentChannelId).not.toHaveBeenCalled(); + expect(fetchPlaybookRunsForChannel).not.toHaveBeenCalled(); + }); }); }); diff --git a/app/products/playbooks/actions/websocket/reconnect.ts b/app/products/playbooks/actions/websocket/reconnect.ts index 61b0d4e23..56b0ac274 100644 --- a/app/products/playbooks/actions/websocket/reconnect.ts +++ b/app/products/playbooks/actions/websocket/reconnect.ts @@ -8,6 +8,7 @@ import {fetchIsPlaybooksEnabled} from '@playbooks/database/queries/version'; import {getCurrentChannelId} from '@queries/servers/system'; import EphemeralStore from '@store/ephemeral_store'; import NavigationStore from '@store/navigation_store'; +import {isTablet} from '@utils/helpers'; import {logDebug} from '@utils/log'; export async function handlePlaybookReconnect(serverUrl: string) { @@ -27,14 +28,24 @@ export async function handlePlaybookReconnect(serverUrl: string) { logDebug('Error updating playbooks version on reconnect', updateResult.error); } - if (NavigationStore.getScreensInStack().includes(Screens.CHANNEL)) { - const isPlaybooksEnabled = await fetchIsPlaybooksEnabled(database); - if (isPlaybooksEnabled) { - const currentChannelId = await getCurrentChannelId(database); - const fetchResult = await fetchPlaybookRunsForChannel(serverUrl, currentChannelId); - if (fetchResult.error) { - logDebug('Error fetching playbook runs on reconnect', fetchResult.error); - } - } + const isChannelScreenMounted = NavigationStore.getScreensInStack().includes(Screens.CHANNEL); + const isTabletDevice = isTablet(); + if (!isChannelScreenMounted && !isTabletDevice) { + return; + } + + const isPlaybooksEnabled = await fetchIsPlaybooksEnabled(database); + if (!isPlaybooksEnabled) { + return; + } + + const currentChannelId = await getCurrentChannelId(database); + if (!currentChannelId) { + return; + } + + const fetchResult = await fetchPlaybookRunsForChannel(serverUrl, currentChannelId); + if (fetchResult.error) { + logDebug('Error fetching playbook runs on reconnect', fetchResult.error); } } diff --git a/app/products/playbooks/client/rest.test.ts b/app/products/playbooks/client/rest.test.ts index db1b46b30..31725ba17 100644 --- a/app/products/playbooks/client/rest.test.ts +++ b/app/products/playbooks/client/rest.test.ts @@ -418,3 +418,25 @@ describe('setAssignee', () => { await expect(client.setAssignee(playbookRunId, checklistNum, itemNum, assigneeId)).rejects.toThrow('Network error'); }); }); + +describe('finishRun', () => { + test('should call doFetch with correct url and options', async () => { + const playbookRunId = 'run123'; + const expectedUrl = `/plugins/playbooks/api/v0/runs/${playbookRunId}/finish`; + const expectedOptions = {body: {}, method: 'put'}; + + jest.mocked(client.doFetch).mockResolvedValue(undefined); + + await client.finishRun(playbookRunId); + + expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions); + }); + + test('should handle error when finishing run', async () => { + const playbookRunId = 'run123'; + + jest.mocked(client.doFetch).mockRejectedValue(new Error('Network error')); + + await expect(client.finishRun(playbookRunId)).rejects.toThrow('Network error'); + }); +}); diff --git a/app/products/playbooks/client/rest.ts b/app/products/playbooks/client/rest.ts index ba6b5145c..539b81f60 100644 --- a/app/products/playbooks/client/rest.ts +++ b/app/products/playbooks/client/rest.ts @@ -13,7 +13,7 @@ export interface ClientPlaybooksMix { setOwner: (playbookRunId: string, ownerId: string) => Promise; // Run Management - // finishRun: (playbookRunId: string) => Promise; + finishRun: (playbookRunId: string) => Promise; // Checklist Management setChecklistItemState: (playbookRunID: string, checklistNum: number, itemNum: number, newState: ChecklistItemState) => Promise; @@ -74,16 +74,12 @@ const ClientPlaybooks = >(superclass: TBas }; // Run Management - // finishRun = async (playbookRunId: string) => { - // try { - // return await this.doFetch( - // `${this.getPlaybookRunRoute(playbookRunId)}/finish`, - // {method: 'put'}, - // ); - // } catch (error) { - // return {error}; - // } - // }; + finishRun = async (playbookRunId: string) => { + return this.doFetch( + `${this.getPlaybookRunRoute(playbookRunId)}/finish`, + {method: 'put', body: {/* okhttp requires put methods to have a body */}}, + ); + }; // Checklist Management setChecklistItemState = async (playbookRunID: string, checklistNum: number, itemNum: number, newState: ChecklistItemState) => { diff --git a/app/products/playbooks/screens/playbook_run/index.test.tsx b/app/products/playbooks/screens/playbook_run/index.test.tsx index fe2a2970b..e2777a678 100644 --- a/app/products/playbooks/screens/playbook_run/index.test.tsx +++ b/app/products/playbooks/screens/playbook_run/index.test.tsx @@ -58,6 +58,11 @@ describe('PlaybookRun', () => { id: 'item-4', due_date: Date.now() + 1000, }), + TestHelper.fakePlaybookChecklistItem(playbookRunId, { + id: 'item-5', + due_date: Date.now() + 1000, + state: 'closed', + }), ], }), ], @@ -95,6 +100,7 @@ describe('PlaybookRun', () => { expect(playbookRun.props.playbookRun).toBe(props.playbookRun); expect(playbookRun.props.checklists).toBe(props.playbookRun!.checklists); expect(playbookRun.props.overdueCount).toBe(2); + expect(playbookRun.props.pendingCount).toBe(4); expect(playbookRun.props.participants).toHaveLength(0); expect(playbookRun.props.owner).toBeUndefined(); @@ -139,6 +145,7 @@ describe('PlaybookRun', () => { expect(playbookRun.props.playbookRun).toBe(props.playbookRun); expect(playbookRun.props.checklists).toBe(props.playbookRun!.checklists); expect(playbookRun.props.overdueCount).toBe(2); + expect(playbookRun.props.pendingCount).toBe(4); expect(playbookRun.props.participants).toHaveLength(1); expect(playbookRun.props.participants[0].id).toBe(participantId); @@ -164,6 +171,7 @@ describe('PlaybookRun', () => { expect(playbookRun.props.playbookRun).toBeUndefined(); expect(playbookRun.props.checklists).toHaveLength(0); expect(playbookRun.props.overdueCount).toBe(0); + expect(playbookRun.props.pendingCount).toBe(0); expect(playbookRun.props.participants).toHaveLength(0); expect(playbookRun.props.owner).toBeUndefined(); @@ -222,6 +230,7 @@ describe('PlaybookRun', () => { expect(playbookRun.props.checklists[0].id).toBe(baseRun.checklists[0].id); expect(playbookRun.props.checklists[1].id).toBe(baseRun.checklists[1].id); expect(playbookRun.props.overdueCount).toBe(2); + expect(playbookRun.props.pendingCount).toBe(4); expect(playbookRun.props.currentUserId).toBe('current-user-id'); expect(playbookRun.props.teammateNameDisplay).toBe(General.TEAMMATE_NAME_DISPLAY.SHOW_FULLNAME); diff --git a/app/products/playbooks/screens/playbook_run/index.ts b/app/products/playbooks/screens/playbook_run/index.ts index c62418c70..374463519 100644 --- a/app/products/playbooks/screens/playbook_run/index.ts +++ b/app/products/playbooks/screens/playbook_run/index.ts @@ -3,13 +3,13 @@ import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {combineLatest, of as of$} from 'rxjs'; -import {distinctUntilChanged, switchMap} from 'rxjs/operators'; +import {distinctUntilChanged, map, switchMap} from 'rxjs/operators'; import {queryPlaybookChecklistByRun} from '@playbooks/database/queries/checklist'; import {queryPlaybookChecklistItemsByChecklists} from '@playbooks/database/queries/item'; import {observeParticipantsIdsFromPlaybookModel, observePlaybookRunById, queryParticipantsFromAPIRun} from '@playbooks/database/queries/run'; import {areItemsOrdersEqual} from '@playbooks/utils/items_order'; -import {isOverdue} from '@playbooks/utils/run'; +import {isOverdue, isPending} from '@playbooks/utils/run'; import {observeCurrentUserId} from '@queries/servers/system'; import {observeTeammateNameDisplay, observeUser, queryUsersById} from '@queries/servers/user'; @@ -42,15 +42,20 @@ const enhanced = withObservables(['playbookRunId', 'playbookRun'], ({playbookRun if (providedRun) { const participants = queryParticipantsFromAPIRun(database, providedRun).observe(); const owner = observeUser(database, providedRun.owner_user_id); - const overdueCount = providedRun.checklists.reduce((acc, c) => { - return acc + c.items.filter(isOverdue).length; - }, 0); + const {pendingCount, overdueCount} = providedRun.checklists.reduce((acc, c) => { + return { + pendingCount: acc.pendingCount + c.items.filter(isPending).length, + overdueCount: acc.overdueCount + c.items.filter(isOverdue).length, + }; + }, {pendingCount: 0, overdueCount: 0}); + return { playbookRun: of$(providedRun), participants, owner, checklists: of$(providedRun.checklists), overdueCount: of$(overdueCount), + pendingCount: of$(pendingCount), currentUserId: observeCurrentUserId(database), teammateNameDisplay: observeTeammateNameDisplay(database), }; @@ -78,14 +83,15 @@ const enhanced = withObservables(['playbookRunId', 'playbookRun'], ({playbookRun distinctUntilChanged((a, b) => areItemsOrdersEqual(getIds(a), getIds(b))), ); - const overdueCount = checklists.pipe( + const countObserver = checklists.pipe( switchMap((cs) => { const ids = getIds(cs); return queryPlaybookChecklistItemsByChecklists(database, ids).observeWithColumns(['due_date', 'state']); }), switchMap((items) => { const overdue = items.filter(isOverdue).length; - return of$(overdue); + const pending = items.filter(isPending).length; + return of$({pendingCount: pending, overdueCount: overdue}); }), ); @@ -94,7 +100,8 @@ const enhanced = withObservables(['playbookRunId', 'playbookRun'], ({playbookRun owner, participants, checklists: orderedChecklists, - overdueCount, + overdueCount: countObserver.pipe(map((c) => c.overdueCount)), + pendingCount: countObserver.pipe(map((c) => c.pendingCount)), currentUserId: observeCurrentUserId(database), teammateNameDisplay: observeTeammateNameDisplay(database), }; diff --git a/app/products/playbooks/screens/playbook_run/playbook_run.test.tsx b/app/products/playbooks/screens/playbook_run/playbook_run.test.tsx index 81dc0bcb5..9e31d126e 100644 --- a/app/products/playbooks/screens/playbook_run/playbook_run.test.tsx +++ b/app/products/playbooks/screens/playbook_run/playbook_run.test.tsx @@ -2,15 +2,16 @@ // See LICENSE.txt for license information. import React, {type ComponentProps} from 'react'; +import {Alert} from 'react-native'; import UserChip from '@components/chips/user_chip'; import UserAvatarsStack from '@components/user_avatars_stack'; import {General} from '@constants'; import {useServerUrl} from '@context/server'; import DatabaseManager from '@database/manager'; -import {setOwner} from '@playbooks/actions/remote/runs'; +import {finishRun, setOwner} from '@playbooks/actions/remote/runs'; import {openUserProfileModal} from '@screens/navigation'; -import {renderWithEverything, waitFor} from '@test/intl-test-helper'; +import {fireEvent, renderWithEverything, waitFor} from '@test/intl-test-helper'; import TestHelper from '@test/test_helper'; import {showPlaybookErrorSnackbar} from '@utils/snack_bar'; @@ -68,6 +69,7 @@ jest.mock('../navigation', () => ({ jest.mock('@playbooks/actions/remote/runs', () => ({ setOwner: jest.fn(), + finishRun: jest.fn(), })); describe('PlaybookRun', () => { @@ -121,6 +123,7 @@ describe('PlaybookRun', () => { componentId: 'PlaybookRun', checklists: mockChecklists, overdueCount: 2, + pendingCount: 3, currentUserId: 'current-user', teammateNameDisplay: General.TEAMMATE_NAME_DISPLAY.SHOW_USERNAME, }; @@ -317,4 +320,58 @@ describe('PlaybookRun', () => { expect(getByTestId('error-state')).toBeTruthy(); }); + + it('renders finish run button when not read only', () => { + const props = getBaseProps(); + props.participants.push(TestHelper.fakeUserModel({id: props.currentUserId})); + + const {getByText} = renderWithEverything(, {database}); + + expect(getByText('Finish Run')).toBeTruthy(); + }); + + it('handles finish run button press', () => { + const props = getBaseProps(); + props.participants.push(TestHelper.fakeUserModel({id: props.currentUserId})); + const {getByText} = renderWithEverything(, {database}); + + const finishRunButton = getByText('Finish Run'); + fireEvent.press(finishRunButton); + + expect(Alert.alert).toHaveBeenCalledWith( + 'Finish Run', + 'There are 3 tasks pending.\n\nAre you sure you want to finish the run for all participants?', + [ + {text: 'Cancel', style: 'cancel'}, + {text: 'Finish', style: 'destructive', onPress: expect.any(Function)}, + ], + ); + const finishAction = jest.mocked(Alert.alert).mock.calls[0][2]![1]; + finishAction.onPress?.(); + expect(finishRun).toHaveBeenCalledWith(serverUrl, props.playbookRun!.id); + }); + + it('does not render finish run button when read only', () => { + const props = getBaseProps(); + const {queryByText} = renderWithEverything(, {database}); + + expect(queryByText('Finish Run')).toBeNull(); + }); + + it('shows the error snackbar when finishing run fails', async () => { + const props = getBaseProps(); + props.participants.push(TestHelper.fakeUserModel({id: props.currentUserId})); + jest.mocked(finishRun).mockResolvedValue({error: 'error'}); + const {getByText} = renderWithEverything(, {database}); + + const finishRunButton = getByText('Finish Run'); + fireEvent.press(finishRunButton); + + const finishAction = jest.mocked(Alert.alert).mock.calls[0][2]![1]; + finishAction.onPress?.(); + + await waitFor(() => { + expect(showPlaybookErrorSnackbar).toHaveBeenCalled(); + }); + }); }); diff --git a/app/products/playbooks/screens/playbook_run/playbook_run.tsx b/app/products/playbooks/screens/playbook_run/playbook_run.tsx index 4ae731455..3c32b2c7b 100644 --- a/app/products/playbooks/screens/playbook_run/playbook_run.tsx +++ b/app/products/playbooks/screens/playbook_run/playbook_run.tsx @@ -3,9 +3,10 @@ import React, {useCallback, useMemo} from 'react'; import {defineMessages, useIntl} from 'react-intl'; -import {View, Text, ScrollView} from 'react-native'; +import {View, Text, ScrollView, Alert} from 'react-native'; import {useSafeAreaInsets} from 'react-native-safe-area-context'; +import Button from '@components/button'; import UserChip from '@components/chips/user_chip'; import Markdown from '@components/markdown'; import Tag from '@components/tag'; @@ -13,7 +14,7 @@ import UserAvatarsStack from '@components/user_avatars_stack'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; -import {setOwner} from '@playbooks/actions/remote/runs'; +import {finishRun, setOwner} from '@playbooks/actions/remote/runs'; import {getRunScheduledTimestamp, isRunFinished} from '@playbooks/utils/run'; import {openUserProfileModal, popTopScreen} from '@screens/navigation'; import {getMarkdownBlockStyles, getMarkdownTextStyles} from '@utils/markdown'; @@ -66,6 +67,26 @@ const messages = defineMessages({ id: 'playbooks.playbook_run.finished', defaultMessage: 'Finished', }, + finishRunDialogTitle: { + id: 'playbooks.playbook_run.finish_run_dialog_title', + defaultMessage: 'Finish Run', + }, + finishRunDialogDescription: { + id: 'playbooks.playbook_run.finish_run_dialog_description', + defaultMessage: 'There are {pendingCount} {pendingCount, plural, =1 {task} other {tasks}} pending.\n\nAre you sure you want to finish the run for all participants?', + }, + finishRunDialogCancel: { + id: 'playbooks.playbook_run.finish_run_dialog_cancel', + defaultMessage: 'Cancel', + }, + finishRunDialogFinish: { + id: 'playbooks.playbook_run.finish_run_dialog_finish', + defaultMessage: 'Finish', + }, + finishRunButton: { + id: 'playbooks.playbook_run.finish_run_button', + defaultMessage: 'Finish Run', + }, }); const getStyleSheet = makeStyleSheetFromTheme((theme) => ({ @@ -75,7 +96,6 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => ({ }, intro: { gap: 32, - marginVertical: 24, }, titleAndDescription: { gap: 10, @@ -118,6 +138,8 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => ({ }, scrollView: { paddingHorizontal: 20, + paddingVertical: 32, + gap: 16, }, markdownContainer: { width: '100%', @@ -131,6 +153,7 @@ type Props = { componentId: AvailableScreens; checklists: Array; overdueCount: number; + pendingCount: number; currentUserId: string; teammateNameDisplay: string; } @@ -141,6 +164,7 @@ export default function PlaybookRun({ participants, checklists, overdueCount, + pendingCount, componentId, currentUserId, teammateNameDisplay, @@ -209,6 +233,33 @@ export default function PlaybookRun({ ); }, [handleSelectOwner, intl, owner, participants, playbookRun?.name, theme]); + const handleFinishRun = useCallback(() => { + if (!playbookRun) { + return; + } + + Alert.alert( + intl.formatMessage(messages.finishRunDialogTitle), + intl.formatMessage(messages.finishRunDialogDescription, {pendingCount}), + [ + { + text: intl.formatMessage(messages.finishRunDialogCancel), + style: 'cancel', + }, + { + text: intl.formatMessage(messages.finishRunDialogFinish), + onPress: async () => { + const res = await finishRun(serverUrl, playbookRun.id); + if (res.error) { + showPlaybookErrorSnackbar(); + } + }, + style: 'destructive', + }, + ], + ); + }, [intl, pendingCount, playbookRun, serverUrl]); + const ownerAction = useMemo(() => { if (readOnly) { return undefined; @@ -308,6 +359,15 @@ export default function PlaybookRun({ isParticipant={isParticipant} /> + {!readOnly && ( +