Add playbooks finish run (#9144)

* Add playbooks finish run

* Add missing texts

* Fix tablet reconnect issue

* Add body to put method

* Fix tests
This commit is contained in:
Daniel Espino García 2025-09-26 17:33:13 +02:00 committed by GitHub
parent f384ddb65d
commit 1cedc7b7e1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 398 additions and 62 deletions

View file

@ -10,7 +10,7 @@ import {getLastPlaybookRunsFetchAt} from '@playbooks/database/queries/run';
import EphemeralStore from '@store/ephemeral_store';
import TestHelper from '@test/test_helper';
import {fetchPlaybookRunsForChannel, fetchFinishedRunsForChannel, setOwner} 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('');
});
});

View file

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

View file

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

View file

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

View file

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

View file

@ -13,7 +13,7 @@ export interface ClientPlaybooksMix {
setOwner: (playbookRunId: string, ownerId: string) => Promise<void>;
// Run Management
// finishRun: (playbookRunId: string) => Promise<any>;
finishRun: (playbookRunId: string) => Promise<void>;
// Checklist Management
setChecklistItemState: (playbookRunID: string, checklistNum: number, itemNum: number, newState: ChecklistItemState) => Promise<void>;
@ -74,16 +74,12 @@ const ClientPlaybooks = <TBase extends Constructor<ClientBase>>(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) => {

View file

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

View file

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

View file

@ -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(<PlaybookRun {...props}/>, {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(<PlaybookRun {...props}/>, {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(<PlaybookRun {...props}/>, {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(<PlaybookRun {...props}/>, {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();
});
});
});

View file

@ -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<PlaybookChecklistModel | PlaybookChecklist>;
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}
/>
</View>
{!readOnly && (
<Button
text={intl.formatMessage(messages.finishRunButton)}
onPress={handleFinishRun}
theme={theme}
size='lg'
emphasis='tertiary'
/>
)}
</ScrollView>
</View>
</>

View file

@ -3,7 +3,7 @@
import TestHelper from '@test/test_helper';
import {getRunScheduledTimestamp, isRunFinished, getMaxRunUpdateAt, isOverdue, isDueSoon} from './run';
import {getRunScheduledTimestamp, isRunFinished, getMaxRunUpdateAt, isOverdue, isDueSoon, isPending} from './run';
describe('run utils', () => {
describe('getRunScheduledTimestamp', () => {
@ -312,4 +312,60 @@ describe('run utils', () => {
expect(isDueSoon(item)).toBe(true);
});
});
describe('isPending', () => {
it('should return true for open items', () => {
const item = TestHelper.fakePlaybookChecklistItem('checklist-id', {
due_date: Date.now() + (2 * 60 * 60 * 1000), // 2 hours from now
state: '',
});
expect(isPending(item)).toBe(true);
});
it('should return true for in_progress items', () => {
const item = TestHelper.fakePlaybookChecklistItem('checklist-id', {
due_date: Date.now() + (2 * 60 * 60 * 1000), // 2 hours from now
state: 'in_progress',
});
expect(isPending(item)).toBe(true);
});
it('should return false for closed items', () => {
const item = TestHelper.fakePlaybookChecklistItem('checklist-id', {
due_date: Date.now() + (2 * 60 * 60 * 1000),
state: 'closed',
});
expect(isPending(item)).toBe(false);
});
it('should return true for items with no due date', () => {
const item = TestHelper.fakePlaybookChecklistItem('checklist-id', {
due_date: 0,
state: '',
});
expect(isPending(item)).toBe(true);
});
it('should return true for items with due date in the past', () => {
const item = TestHelper.fakePlaybookChecklistItem('checklist-id', {
due_date: Date.now() - (60 * 60 * 1000), // 1 hour ago
state: '',
});
expect(isPending(item)).toBe(true);
});
it('should handle database model with different property name', () => {
const item = TestHelper.fakePlaybookChecklistItemModel({
id: 'checklist-id',
dueDate: Date.now() + (2 * 60 * 60 * 1000),
state: '',
});
expect(isPending(item)).toBe(true);
});
});
});

View file

@ -41,13 +41,17 @@ export function getMaxRunUpdateAt(runs: PlaybookRun[]): number {
return max;
}
export function isPending(item: PlaybookChecklistItemModel | PlaybookChecklistItem): boolean {
return item.state === '' || item.state === 'in_progress';
}
export function isOverdue(item: PlaybookChecklistItemModel | PlaybookChecklistItem): boolean {
const dueDate = 'dueDate' in item ? item.dueDate : item.due_date;
if (dueDate <= 0) {
return false;
}
if (item.state !== '' && item.state !== 'in_progress') {
if (!isPending(item)) {
return false;
}
@ -60,7 +64,7 @@ export function isDueSoon(item: PlaybookChecklistItemModel | PlaybookChecklistIt
return false;
}
if (item.state !== '' && item.state !== 'in_progress') {
if (!isPending(item)) {
return false;
}

View file

@ -1019,6 +1019,11 @@
"playbooks.only_runs_available.title": "Playbooks not available",
"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",
"playbooks.playbook_run.finish_run_dialog_cancel": "Cancel",
"playbooks.playbook_run.finish_run_dialog_description": "There are {pendingCount} {pendingCount, plural, =1 {task} other {tasks}} pending.\n\nAre you sure you want to finish the run for all participants?",
"playbooks.playbook_run.finish_run_dialog_finish": "Finish",
"playbooks.playbook_run.finish_run_dialog_title": "Finish Run",
"playbooks.playbook_run.finished": "Finished",
"playbooks.playbook_run.overdue": "{num} {num, plural, =1 {task} other {tasks}} overdue",
"playbooks.playbook_run.owner": "Owner",