Add bottom sheet for playbooks checklist items (#9065)

* Add bottom sheet for playbooks checklist items

* i18n extract

* Remove unneeded test case

* Add tests

* Address feedback

* Remove unneeded useCallback and add needed useMemo

* Address feedback

* Add missing translation string
This commit is contained in:
Daniel Espino García 2025-08-25 12:37:29 +02:00 committed by GitHub
parent 0c4a42a06a
commit 8aa13c4e0a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 1035 additions and 46 deletions

View file

@ -49,6 +49,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
actionContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-end',
},
container: {
flexDirection: 'row',
@ -97,6 +98,9 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
color: theme.centerChannelColor,
...typography('Body', 200),
},
shrink: {
flexShrink: 1,
},
};
});
@ -184,9 +188,9 @@ const OptionItem = ({
]);
const actionContainerStyle = useMemo(() => {
const extraStyle = longInfo ? {maxWidth: 300} : {};
const extraStyle = longInfo ? styles.shrink : {};
return [styles.actionContainer, extraStyle];
}, [longInfo, styles.actionContainer]);
}, [longInfo, styles.actionContainer, styles.shrink]);
let actionComponent;
let radioComponent;
@ -277,6 +281,15 @@ const OptionItem = ({
{info}
</Text>
);
if (actionComponent) {
// Wrap the text into another view to properly calculate
// the space available.
infoComponent = (
<View style={styles.shrink}>
{infoComponent}
</View>
);
}
}
const component = (

View file

@ -5,7 +5,7 @@ import DatabaseManager from '@database/manager';
import NetworkManager from '@managers/network_manager';
import {updateChecklistItem as localUpdateChecklistItem} from '@playbooks/actions/local/checklist';
import {updateChecklistItem, runChecklistItem} from './checklist';
import {updateChecklistItem, runChecklistItem, skipChecklistItem, restoreChecklistItem} from './checklist';
const serverUrl = 'baseHandler.test.com';
@ -17,6 +17,8 @@ const itemNumber = 1;
const mockClient = {
setChecklistItemState: jest.fn(),
runChecklistItemSlashCommand: jest.fn(),
skipChecklistItem: jest.fn(),
restoreChecklistItem: jest.fn(),
};
jest.mock('@playbooks/actions/local/checklist');
@ -49,15 +51,6 @@ describe('checklist', () => {
expect(localUpdateChecklistItem).not.toHaveBeenCalled();
});
it('should handle API error response', async () => {
mockClient.setChecklistItemState.mockResolvedValueOnce({error: 'API error'});
const result = await updateChecklistItem(serverUrl, playbookRunId, itemId, checklistNumber, itemNumber, 'closed');
expect(result).toBeDefined();
expect(result.error).toBe('API error');
expect(localUpdateChecklistItem).not.toHaveBeenCalled();
});
it('should update checklist item successfully', async () => {
mockClient.setChecklistItemState.mockResolvedValueOnce({});
@ -97,4 +90,66 @@ describe('checklist', () => {
expect(mockClient.runChecklistItemSlashCommand).toHaveBeenCalledWith(playbookRunId, checklistNumber, itemNumber);
});
});
describe('skipChecklistItem', () => {
it('should handle client error', async () => {
jest.spyOn(NetworkManager, 'getClient').mockImplementationOnce(throwFunc);
const result = await skipChecklistItem(serverUrl, playbookRunId, itemId, checklistNumber, itemNumber);
expect(result).toBeDefined();
expect(result.error).toBeDefined();
expect(mockClient.skipChecklistItem).not.toHaveBeenCalled();
expect(localUpdateChecklistItem).not.toHaveBeenCalled();
});
it('should handle API exception', async () => {
mockClient.skipChecklistItem.mockImplementationOnce(throwFunc);
const result = await skipChecklistItem(serverUrl, playbookRunId, itemId, checklistNumber, itemNumber);
expect(result).toBeDefined();
expect(result.error).toBeDefined();
expect(mockClient.skipChecklistItem).toHaveBeenCalledWith(playbookRunId, checklistNumber, itemNumber);
expect(localUpdateChecklistItem).not.toHaveBeenCalled();
});
it('should skip checklist item successfully', async () => {
mockClient.skipChecklistItem.mockResolvedValueOnce({});
const result = await skipChecklistItem(serverUrl, playbookRunId, itemId, checklistNumber, itemNumber);
expect(result).toBeDefined();
expect(result.error).toBeUndefined();
expect(result.data).toBe(true);
expect(mockClient.skipChecklistItem).toHaveBeenCalledWith(playbookRunId, checklistNumber, itemNumber);
expect(localUpdateChecklistItem).toHaveBeenCalledWith(serverUrl, itemId, 'skipped');
});
});
describe('restoreChecklistItem', () => {
it('should handle client error', async () => {
jest.spyOn(NetworkManager, 'getClient').mockImplementationOnce(throwFunc);
const result = await restoreChecklistItem(serverUrl, playbookRunId, itemId, checklistNumber, itemNumber);
expect(result).toBeDefined();
expect(result.error).toBeDefined();
});
it('should handle API exception', async () => {
mockClient.restoreChecklistItem.mockImplementationOnce(throwFunc);
const result = await restoreChecklistItem(serverUrl, playbookRunId, itemId, checklistNumber, itemNumber);
expect(result).toBeDefined();
expect(result.error).toBeDefined();
});
it('should restore checklist item successfully', async () => {
mockClient.restoreChecklistItem.mockResolvedValueOnce({});
const result = await restoreChecklistItem(serverUrl, playbookRunId, itemId, checklistNumber, itemNumber);
expect(result).toBeDefined();
expect(result.error).toBeUndefined();
expect(result.data).toBe(true);
expect(mockClient.restoreChecklistItem).toHaveBeenCalledWith(playbookRunId, checklistNumber, itemNumber);
expect(localUpdateChecklistItem).toHaveBeenCalledWith(serverUrl, itemId, '');
});
});
});

View file

@ -18,14 +18,11 @@ export const updateChecklistItem = async (
try {
const client = NetworkManager.getClient(serverUrl);
const res = await client.setChecklistItemState(playbookRunId, checklistNumber, itemNumber, state);
if (res.error) {
return {error: res.error};
}
await client.setChecklistItemState(playbookRunId, checklistNumber, itemNumber, state);
await localUpdateChecklistItem(serverUrl, itemId, state);
return {data: true};
} catch (error) {
logDebug('error on fetchPlaybookRunsForChannel', getFullErrorMessage(error));
logDebug('error on updateChecklistItem', getFullErrorMessage(error));
forceLogoutIfNecessary(serverUrl, error);
return {error};
}
@ -47,3 +44,43 @@ export const runChecklistItem = async (
return {error};
}
};
export const skipChecklistItem = async (
serverUrl: string,
playbookRunId: string,
itemId: string,
checklistNumber: number,
itemNumber: number,
) => {
try {
const client = NetworkManager.getClient(serverUrl);
await client.skipChecklistItem(playbookRunId, checklistNumber, itemNumber);
await localUpdateChecklistItem(serverUrl, itemId, 'skipped');
return {data: true};
} catch (error) {
logDebug('error on skipChecklistItem', getFullErrorMessage(error));
forceLogoutIfNecessary(serverUrl, error);
return {error};
}
};
export const restoreChecklistItem = async (
serverUrl: string,
playbookRunId: string,
itemId: string,
checklistNumber: number,
itemNumber: number,
) => {
try {
const client = NetworkManager.getClient(serverUrl);
await client.restoreChecklistItem(playbookRunId, checklistNumber, itemNumber);
await localUpdateChecklistItem(serverUrl, itemId, '');
return {data: true};
} catch (error) {
logDebug('error on restoreChecklistItem', getFullErrorMessage(error));
forceLogoutIfNecessary(serverUrl, error);
return {error};
}
};

View file

@ -172,3 +172,84 @@ describe('runChecklistItemSlashCommand', () => {
await expect(client.runChecklistItemSlashCommand(playbookRunId, checklistNumber, itemNumber)).rejects.toThrow('Network error');
});
});
describe('skipChecklistItem', () => {
test('should skip checklist item successfully', async () => {
const playbookRunID = 'run123';
const checklistNum = 1;
const itemNum = 2;
const expectedUrl = `/plugins/playbooks/api/v0/runs/${playbookRunID}/checklists/${checklistNum}/item/${itemNum}/skip`;
const expectedOptions = {method: 'put', body: ''};
jest.mocked(client.doFetch).mockResolvedValue(undefined);
await client.skipChecklistItem(playbookRunID, checklistNum, itemNum);
expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions);
});
test('should skip checklist item with zero indices', async () => {
const playbookRunID = 'run123';
const checklistNum = 0;
const itemNum = 0;
const expectedUrl = `/plugins/playbooks/api/v0/runs/${playbookRunID}/checklists/${checklistNum}/item/${itemNum}/skip`;
const expectedOptions = {method: 'put', body: ''};
jest.mocked(client.doFetch).mockResolvedValue(undefined);
await client.skipChecklistItem(playbookRunID, checklistNum, itemNum);
expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions);
});
test('should handle error when skipping checklist item', async () => {
const playbookRunID = 'run123';
const checklistNum = 1;
const itemNum = 2;
jest.mocked(client.doFetch).mockRejectedValue(new Error('Network error'));
await expect(client.skipChecklistItem(playbookRunID, checklistNum, itemNum)).rejects.toThrow('Network error');
});
});
describe('restoreChecklistItem', () => {
test('should restore checklist item successfully', async () => {
const playbookRunID = 'run123';
const checklistNum = 1;
const itemNum = 2;
const expectedUrl = `/plugins/playbooks/api/v0/runs/${playbookRunID}/checklists/${checklistNum}/item/${itemNum}/restore`;
const expectedOptions = {method: 'put', body: ''};
jest.mocked(client.doFetch).mockResolvedValue(undefined);
await client.restoreChecklistItem(playbookRunID, checklistNum, itemNum);
expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions);
});
test('should restore checklist item with zero indices', async () => {
const playbookRunID = 'run123';
const checklistNum = 0;
const itemNum = 0;
const expectedUrl = `/plugins/playbooks/api/v0/runs/${playbookRunID}/checklists/${checklistNum}/item/${itemNum}/restore`;
const expectedOptions = {method: 'put', body: ''};
jest.mocked(client.doFetch).mockResolvedValue(undefined);
await client.restoreChecklistItem(playbookRunID, checklistNum, itemNum);
expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions);
});
test('should handle error when restoring checklist item', async () => {
const playbookRunID = 'run123';
const checklistNum = 1;
const itemNum = 2;
jest.mocked(client.doFetch).mockRejectedValue(new Error('Network error'));
await expect(client.restoreChecklistItem(playbookRunID, checklistNum, itemNum)).rejects.toThrow('Network error');
});
});

View file

@ -9,16 +9,16 @@ export interface ClientPlaybooksMix {
// Playbook Runs
fetchPlaybookRuns: (params: FetchPlaybookRunsParams, groupLabel?: RequestGroupLabel) => Promise<FetchPlaybookRunsReturn>;
fetchPlaybookRun: (id: string, groupLabel?: RequestGroupLabel) => Promise<PlaybookRun>;
// Run Management
// finishRun: (playbookRunId: string) => Promise<any>;
// Checklist Management
setChecklistItemState: (playbookRunID: string, checklistNum: number, itemNum: number, newState: ChecklistItemState) => Promise<any>;
setChecklistItemState: (playbookRunID: string, checklistNum: number, itemNum: number, newState: ChecklistItemState) => Promise<void>;
skipChecklistItem: (playbookRunID: string, checklistNum: number, itemNum: number) => Promise<void>;
restoreChecklistItem: (playbookRunID: string, checklistNum: number, itemNum: number) => Promise<void>;
// skipChecklistItem: (playbookRunID: string, checklistNum: number, itemNum: number) => Promise<void>;
// skipChecklist: (playbookRunID: string, checklistNum: number) => Promise<void>;
// Slash Commands
@ -85,12 +85,19 @@ const ClientPlaybooks = <TBase extends Constructor<ClientBase>>(superclass: TBas
}
};
// skipChecklistItem = async (playbookRunID: string, checklistNum: number, itemNum: number) => {
// await this.doFetch(
// `${this.getPlaybookRunRoute(playbookRunID)}/checklists/${checklistNum}/item/${itemNum}/skip`,
// {method: 'put', body: ''},
// );
// };
skipChecklistItem = async (playbookRunID: string, checklistNum: number, itemNum: number) => {
await this.doFetch(
`${this.getPlaybookRunRoute(playbookRunID)}/checklists/${checklistNum}/item/${itemNum}/skip`,
{method: 'put', body: ''},
);
};
restoreChecklistItem = async (playbookRunID: string, checklistNum: number, itemNum: number) => {
await this.doFetch(
`${this.getPlaybookRunRoute(playbookRunID)}/checklists/${checklistNum}/item/${itemNum}/restore`,
{method: 'put', body: ''},
);
};
// skipChecklist = async (playbookRunID: string, checklistNum: number) => {
// await this.doFetch(

View file

@ -1,21 +1,22 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {act, waitFor} from '@testing-library/react-native';
import {act, fireEvent, waitFor} from '@testing-library/react-native';
import React, {type ComponentProps} from 'react';
import BaseChip from '@components/chips/base_chip';
import UserChip from '@components/chips/user_chip';
import {Preferences} from '@constants';
import {useServerUrl} from '@context/server';
import {runChecklistItem, updateChecklistItem} from '@playbooks/actions/remote/checklist';
import {openUserProfileModal, popTo} from '@screens/navigation';
import {runChecklistItem, skipChecklistItem, updateChecklistItem} from '@playbooks/actions/remote/checklist';
import {bottomSheet, openUserProfileModal, popTo} from '@screens/navigation';
import {renderWithIntl} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
import {showPlaybookErrorSnackbar} from '@utils/snack_bar';
import Checkbox from './checkbox';
import ChecklistItem from './checklist_item';
import ChecklistItemBottomSheet from './checklist_item_bottom_sheet';
const serverUrl = 'some.server.url';
jest.mock('@context/server');
@ -30,6 +31,9 @@ jest.mocked(UserChip).mockImplementation((props) => React.createElement('UserChi
jest.mock('@components/chips/base_chip');
jest.mocked(BaseChip).mockImplementation((props) => React.createElement('BaseChip', {...props, testID: 'base-chip-component'}));
jest.mock('./checklist_item_bottom_sheet');
jest.mocked(ChecklistItemBottomSheet).mockImplementation((props) => React.createElement('ChecklistItemBottomSheet', {...props, testID: 'checklist-item-bottom-sheet-component'}));
jest.mock('@playbooks/actions/remote/checklist');
jest.mock('@utils/snack_bar');
@ -345,4 +349,47 @@ describe('ChecklistItem', () => {
expect(popTo).not.toHaveBeenCalled();
});
});
it('opens the bottom sheet when the item is pressed', async () => {
const props = getBaseProps();
const {getByText} = renderWithIntl(<ChecklistItem {...props}/>);
const item = getByText('Test Item');
act(() => {
fireEvent.press(item);
});
expect(bottomSheet).toHaveBeenCalled();
const args = jest.mocked(bottomSheet).mock.calls[0][0] as Parameters<typeof bottomSheet>[0];
expect(args.title).toBe('Task Details');
expect(args.snapPoints).toEqual([1, 354, '80%']);
expect(args.theme).toBe(Preferences.THEMES.denim);
expect(args.closeButtonId).toBe('close-checklist-item');
const BottomSheetComponent = args.renderContent;
const {getByTestId} = renderWithIntl(<BottomSheetComponent/>);
const bottomSheetRenderedComponent = getByTestId('checklist-item-bottom-sheet-component');
expect(bottomSheetRenderedComponent.props.item).toBe(props.item);
expect(bottomSheetRenderedComponent.props.assignee).toBe(props.assignee);
expect(bottomSheetRenderedComponent.props.teammateNameDisplay).toBe(props.teammateNameDisplay);
bottomSheetRenderedComponent.props.onCheck();
await waitFor(() => {
expect(updateChecklistItem).toHaveBeenCalledWith(serverUrl, props.playbookRunId, props.item.id, props.checklistNumber, props.itemNumber, 'closed');
});
bottomSheetRenderedComponent.props.onSkip();
await waitFor(() => {
expect(skipChecklistItem).toHaveBeenCalledWith(serverUrl, props.playbookRunId, props.item.id, props.checklistNumber, props.itemNumber);
});
bottomSheetRenderedComponent.props.onRunCommand();
await waitFor(() => {
expect(runChecklistItem).toHaveBeenCalledWith(serverUrl, props.playbookRunId, props.checklistNumber, props.itemNumber);
});
});
});

View file

@ -9,17 +9,19 @@ import BaseChip from '@components/chips/base_chip';
import UserChip from '@components/chips/user_chip';
import CompassIcon from '@components/compass_icon';
import {getFriendlyDate} from '@components/friendly_date';
import PressableOpacity from '@components/pressable_opacity';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {runChecklistItem, updateChecklistItem} from '@playbooks/actions/remote/checklist';
import {restoreChecklistItem, runChecklistItem, skipChecklistItem, updateChecklistItem} from '@playbooks/actions/remote/checklist';
import {isDueSoon, isOverdue} from '@playbooks/utils/run';
import {openUserProfileModal, popTo} from '@screens/navigation';
import {bottomSheet, openUserProfileModal, popTo} from '@screens/navigation';
import {logDebug} from '@utils/log';
import {showPlaybookErrorSnackbar} from '@utils/snack_bar';
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
import {typography} from '@utils/typography';
import Checkbox from './checkbox';
import ChecklistItemBottomSheet, {BOTTOM_SHEET_HEIGHT} from './checklist_item_bottom_sheet';
import type PlaybookChecklistItemModel from '@playbooks/types/database/models/playbook_checklist_item';
import type UserModel from '@typings/database/models/servers/user';
@ -137,6 +139,23 @@ const ChecklistItem = ({
setIsChecking(false);
}, [isChecking, serverUrl, playbookRunId, item.id, checklistNumber, itemNumber, checked]);
const toggleSkipped = useCallback(async () => {
if (isChecking) {
return;
}
setIsChecking(true);
let res;
if (skipped) {
res = await restoreChecklistItem(serverUrl, playbookRunId, item.id, checklistNumber, itemNumber);
} else {
res = await skipChecklistItem(serverUrl, playbookRunId, item.id, checklistNumber, itemNumber);
}
if (res.error) {
showPlaybookErrorSnackbar();
}
setIsChecking(false);
}, [isChecking, serverUrl, playbookRunId, item.id, checklistNumber, itemNumber, skipped]);
const chipIconStyle = useMemo(() => {
return [
styles.chipIcon,
@ -172,18 +191,44 @@ const ChecklistItem = ({
}
}
const renderBottomSheet = useCallback(() => (
<ChecklistItemBottomSheet
item={item}
assignee={assignee}
onCheck={toggleChecked}
onSkip={toggleSkipped}
onRunCommand={executeCommand}
teammateNameDisplay={teammateNameDisplay}
isDisabled={isDisabled}
/>
), [assignee, executeCommand, item, teammateNameDisplay, toggleChecked, toggleSkipped, isDisabled]);
const onPress = useCallback(() => {
const initialHeight = BOTTOM_SHEET_HEIGHT.base + (isDisabled ? 0 : BOTTOM_SHEET_HEIGHT.actionButtons);
bottomSheet({
title: intl.formatMessage({id: 'playbook_run.checklist.taskDetails', defaultMessage: 'Task Details'}),
renderContent: renderBottomSheet,
snapPoints: [1, initialHeight, '80%'],
theme,
closeButtonId: 'close-checklist-item',
scrollable: true,
});
}, [intl, isDisabled, renderBottomSheet, theme]);
return (
<View style={styles.checklistItem}>
<View style={styles.checkboxContainer}>
{checkbox}
</View>
<View style={styles.itemDetails}>
<View style={styles.itemDetailsTexts}>
<Text style={[styles.itemTitle, skipped && styles.skippedText]}>{item.title}</Text>
{item.description && (
<Text style={[styles.itemDescription, skipped && styles.skippedText]}>{item.description}</Text>
)}
</View>
<PressableOpacity onPress={onPress}>
<View style={styles.itemDetailsTexts}>
<Text style={[styles.itemTitle, skipped && styles.skippedText]}>{item.title}</Text>
{Boolean(item.description) && (
<Text style={[styles.itemDescription, skipped && styles.skippedText]}>{item.description}</Text>
)}
</View>
</PressableOpacity>
{(assignee || dueDate || (item.command)) && (
<View style={styles.chipsRow}>
@ -210,7 +255,7 @@ const ChecklistItem = ({
/>
)}
{item.command && (
{Boolean(item.command) && (
<BaseChip
label={commandMessage}
onPress={executeCommand}

View file

@ -0,0 +1,395 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {BottomSheetScrollView} from '@gorhom/bottom-sheet';
import {act, fireEvent} from '@testing-library/react-native';
import React, {type ComponentProps} from 'react';
import {ScrollView} from 'react-native';
import OptionBox from '@components/option_box';
import OptionItem from '@components/option_item';
import {useIsTablet} from '@hooks/device';
import {dismissBottomSheet, openUserProfileModal} from '@screens/navigation';
import {renderWithIntl} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
import ChecklistItemBottomSheet from './checklist_item_bottom_sheet';
jest.mock('@hooks/device', () => ({
useIsTablet: jest.fn(),
}));
jest.mock('@gorhom/bottom-sheet', () => ({
BottomSheetScrollView: jest.fn(),
}));
jest.mocked(BottomSheetScrollView).mockImplementation((props: any) => <ScrollView {...props}/>); // casting to any to avoid type errors
jest.mock('@components/option_box');
jest.mocked(OptionBox).mockImplementation((props) => React.createElement('OptionBox', props));
jest.mock('@components/option_item');
jest.mocked(OptionItem).mockImplementation((props) => React.createElement('OptionItem', props));
describe('ChecklistItemBottomSheet', () => {
const mockOnCheck = jest.fn();
const mockOnSkip = jest.fn();
const mockOnRunCommand = jest.fn();
const mockTeammateNameDisplay = 'username';
const mockAssignee = TestHelper.fakeUserModel({
id: 'user-1',
username: 'testuser',
firstName: 'Test',
lastName: 'User',
});
const mockItem = TestHelper.fakePlaybookChecklistItemModel({
id: 'item-1',
title: 'Test Checklist Item',
description: 'This is a test description',
state: '',
command: 'test command',
dueDate: 0,
commandLastRun: 0,
});
const mockApiItem = TestHelper.fakePlaybookChecklistItem('checklistId', {
id: 'item-2',
title: 'API Checklist Item',
description: 'This is an API item description',
state: 'closed',
command: 'api command',
due_date: 1640995200000, // 2022-01-01
command_last_run: 1640995200000,
});
function getBaseProps(): ComponentProps<typeof ChecklistItemBottomSheet> {
return {
item: mockItem,
assignee: mockAssignee,
onCheck: mockOnCheck,
onSkip: mockOnSkip,
onRunCommand: mockOnRunCommand,
teammateNameDisplay: mockTeammateNameDisplay,
isDisabled: false,
};
}
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(useIsTablet).mockReturnValue(false);
});
it('renders correctly with all props', () => {
const props = getBaseProps();
const {getByTestId, getByText} = renderWithIntl(<ChecklistItemBottomSheet {...props}/>);
expect(getByText('Test Checklist Item')).toBeVisible();
expect(getByText('This is a test description')).toBeVisible();
expect(getByTestId('checklist_item.check_button')).toBeVisible();
expect(getByTestId('checklist_item.skip_button')).toBeVisible();
expect(getByTestId('checklist_item.run_command_button')).toBeVisible();
expect(getByTestId('checklist_item.assignee')).toBeVisible();
expect(getByTestId('checklist_item.due_date')).toBeVisible();
expect(getByTestId('checklist_item.command')).toBeVisible();
});
it('renders correctly without description', () => {
const props = getBaseProps();
props.item.description = '';
const {getByText, queryByText} = renderWithIntl(<ChecklistItemBottomSheet {...props}/>);
expect(getByText('Test Checklist Item')).toBeVisible();
expect(queryByText('This is a test description')).toBeNull();
});
it('renders correctly without assignee', () => {
const props = getBaseProps();
props.assignee = undefined;
const {getByTestId} = renderWithIntl(<ChecklistItemBottomSheet {...props}/>);
const assigneeOption = getByTestId('checklist_item.assignee');
expect(assigneeOption.props.info).toBe('None');
});
it('renders correctly with API item type', () => {
const props = getBaseProps();
props.item = mockApiItem;
const {getByText, getByTestId} = renderWithIntl(<ChecklistItemBottomSheet {...props}/>);
expect(getByText('API Checklist Item')).toBeVisible();
expect(getByText('This is an API item description')).toBeVisible();
expect(getByTestId('checklist_item.check_button')).toBeVisible();
expect(getByTestId('checklist_item.skip_button')).toBeVisible();
expect(getByTestId('checklist_item.run_command_button')).toBeVisible();
expect(getByTestId('checklist_item.assignee')).toBeVisible();
expect(getByTestId('checklist_item.due_date')).toBeVisible();
expect(getByTestId('checklist_item.command')).toBeVisible();
});
it('handles check action correctly', async () => {
const props = getBaseProps();
const {getByTestId} = renderWithIntl(<ChecklistItemBottomSheet {...props}/>);
const checkButton = getByTestId('checklist_item.check_button');
await act(async () => {
fireEvent.press(checkButton);
});
expect(mockOnCheck).toHaveBeenCalledTimes(1);
expect(dismissBottomSheet).toHaveBeenCalledTimes(1);
});
it('handles skip action correctly', async () => {
const props = getBaseProps();
const {getByTestId} = renderWithIntl(<ChecklistItemBottomSheet {...props}/>);
const skipButton = getByTestId('checklist_item.skip_button');
await act(async () => {
fireEvent.press(skipButton);
});
expect(mockOnSkip).toHaveBeenCalledTimes(1);
expect(dismissBottomSheet).toHaveBeenCalledTimes(1);
});
it('handles run command action correctly', async () => {
const props = getBaseProps();
const {getByTestId} = renderWithIntl(<ChecklistItemBottomSheet {...props}/>);
const runCommandButton = getByTestId('checklist_item.run_command_button');
await act(async () => {
fireEvent.press(runCommandButton);
});
expect(mockOnRunCommand).toHaveBeenCalledTimes(1);
expect(dismissBottomSheet).toHaveBeenCalledTimes(1);
});
it('displays correct button states for checked item', () => {
const props = getBaseProps();
props.item = TestHelper.fakePlaybookChecklistItemModel({
...props.item,
state: 'closed',
});
const {getByTestId} = renderWithIntl(<ChecklistItemBottomSheet {...props}/>);
const checkButton = getByTestId('checklist_item.check_button');
const skipButton = getByTestId('checklist_item.skip_button');
const runCommandButton = getByTestId('checklist_item.run_command_button');
expect(checkButton.props.isActive).toBe(true);
expect(skipButton.props.isActive).toBe(false);
expect(runCommandButton.props.isActive).toBe(false);
});
it('displays correct button states for skipped item', () => {
const props = getBaseProps();
props.item = TestHelper.fakePlaybookChecklistItemModel({
...props.item,
state: 'skipped',
});
const {getByTestId} = renderWithIntl(<ChecklistItemBottomSheet {...props}/>);
const checkButton = getByTestId('checklist_item.check_button');
const skipButton = getByTestId('checklist_item.skip_button');
const runCommandButton = getByTestId('checklist_item.run_command_button');
expect(checkButton.props.isActive).toBe(false);
expect(skipButton.props.isActive).toBe(true);
expect(runCommandButton.props.isActive).toBe(false);
});
it('displays correct button states for command run item', () => {
const props = getBaseProps();
props.item = TestHelper.fakePlaybookChecklistItemModel({
...props.item,
commandLastRun: Date.now(),
});
const {getByTestId} = renderWithIntl(<ChecklistItemBottomSheet {...props}/>);
const checkButton = getByTestId('checklist_item.check_button');
const skipButton = getByTestId('checklist_item.skip_button');
const runCommandButton = getByTestId('checklist_item.run_command_button');
expect(checkButton.props.isActive).toBe(false);
expect(skipButton.props.isActive).toBe(false);
expect(runCommandButton.props.isActive).toBe(true);
});
it('displays correct button states for API item with command run', () => {
const props = getBaseProps();
props.item = mockApiItem;
const {getByTestId} = renderWithIntl(<ChecklistItemBottomSheet {...props}/>);
const checkButton = getByTestId('checklist_item.check_button');
const skipButton = getByTestId('checklist_item.skip_button');
const runCommandButton = getByTestId('checklist_item.run_command_button');
expect(checkButton.props.isActive).toBe(true); // state is 'closed'
expect(skipButton.props.isActive).toBe(false);
expect(runCommandButton.props.isActive).toBe(true); // command_last_run exists
});
it('displays correct assignee information', () => {
const props = getBaseProps();
const {getByTestId} = renderWithIntl(<ChecklistItemBottomSheet {...props}/>);
const assigneeItem = getByTestId('checklist_item.assignee');
expect(assigneeItem.props.info).toEqual({
user: mockAssignee,
onPress: expect.any(Function),
teammateNameDisplay: mockTeammateNameDisplay,
location: 'PlaybookRun',
});
});
it('displays correct due date information', () => {
const props = getBaseProps();
props.item = TestHelper.fakePlaybookChecklistItemModel({
...props.item,
dueDate: 1640995200000, // 2022-01-01
});
const {getByTestId} = renderWithIntl(<ChecklistItemBottomSheet {...props}/>);
const dueDateItem = getByTestId('checklist_item.due_date');
expect(dueDateItem.props.info).toBe('Saturday, January 1');
});
it('displays correct due date when within a day', () => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2022-01-01').getTime());
const props = getBaseProps();
props.item = TestHelper.fakePlaybookChecklistItemModel({
...props.item,
dueDate: 1640995200000, // 2022-01-01
});
const {getByTestId} = renderWithIntl(<ChecklistItemBottomSheet {...props}/>);
const dueDateItem = getByTestId('checklist_item.due_date');
expect(dueDateItem.props.info).toBe('Saturday, January 1 at 12:00 AM');
jest.useRealTimers();
});
it('displays "None" for due date when no due date is set', () => {
const props = getBaseProps();
props.item = TestHelper.fakePlaybookChecklistItemModel({
...props.item,
dueDate: 0,
});
const {getByTestId} = renderWithIntl(<ChecklistItemBottomSheet {...props}/>);
const dueDateItem = getByTestId('checklist_item.due_date');
expect(dueDateItem.props.info).toBe('None');
});
it('displays correct command information', () => {
const props = getBaseProps();
props.item = TestHelper.fakePlaybookChecklistItemModel({
...props.item,
command: 'test command',
});
const {getByTestId} = renderWithIntl(<ChecklistItemBottomSheet {...props}/>);
const commandItem = getByTestId('checklist_item.command');
expect(commandItem.props.info).toBe('test command');
});
it('displays "None" for command when no command is set', () => {
const props = getBaseProps();
props.item = TestHelper.fakePlaybookChecklistItemModel({
...props.item,
command: null,
});
const {getByTestId} = renderWithIntl(<ChecklistItemBottomSheet {...props}/>);
const commandItem = getByTestId('checklist_item.command');
expect(commandItem.props.info).toBe('None');
});
it('handles user profile modal opening correctly', async () => {
const props = getBaseProps();
const {getByTestId} = renderWithIntl(<ChecklistItemBottomSheet {...props}/>);
const assigneeItem = getByTestId('checklist_item.assignee');
const onPress = assigneeItem.props.info.onPress;
await act(async () => {
onPress('user-1');
});
expect(openUserProfileModal).toHaveBeenCalledWith(
expect.anything(), // intl
expect.anything(), // theme
{
userId: 'user-1',
location: 'PlaybookRun',
},
);
});
it('displays correct assignee label and icon', () => {
const props = getBaseProps();
const {getByTestId} = renderWithIntl(<ChecklistItemBottomSheet {...props}/>);
const assigneeItem = getByTestId('checklist_item.assignee');
expect(assigneeItem.props.label).toBe('Assignee');
expect(assigneeItem.props.icon).toBe('account-multiple-plus-outline');
});
it('displays correct due date label and icon', () => {
const props = getBaseProps();
const {getByTestId} = renderWithIntl(<ChecklistItemBottomSheet {...props}/>);
const dueDateItem = getByTestId('checklist_item.due_date');
expect(dueDateItem.props.label).toBe('Due date');
expect(dueDateItem.props.icon).toBe('calendar-outline');
});
it('displays correct command label and icon', () => {
const props = getBaseProps();
const {getByTestId} = renderWithIntl(<ChecklistItemBottomSheet {...props}/>);
const commandItem = getByTestId('checklist_item.command');
expect(commandItem.props.label).toBe('Command');
expect(commandItem.props.icon).toBe('slash-forward');
});
it('displays correct button icons', () => {
const props = getBaseProps();
const {getByTestId} = renderWithIntl(<ChecklistItemBottomSheet {...props}/>);
const checkButton = getByTestId('checklist_item.check_button');
const skipButton = getByTestId('checklist_item.skip_button');
const runCommandButton = getByTestId('checklist_item.run_command_button');
expect(checkButton.props.iconName).toBe('check');
expect(skipButton.props.iconName).toBe('close');
expect(runCommandButton.props.iconName).toBe('slash-forward');
});
it('does not render action buttons when isDisabled is true', () => {
const props = getBaseProps();
props.isDisabled = true;
const {queryByTestId} = renderWithIntl(<ChecklistItemBottomSheet {...props}/>);
expect(queryByTestId('checklist_item.check_button')).toBeNull();
expect(queryByTestId('checklist_item.skip_button')).toBeNull();
expect(queryByTestId('checklist_item.run_command_button')).toBeNull();
});
it('does not render command when command is undefined', () => {
const props = getBaseProps();
props.item = TestHelper.fakePlaybookChecklistItemModel({
...props.item,
command: undefined,
});
const {queryByTestId} = renderWithIntl(<ChecklistItemBottomSheet {...props}/>);
expect(queryByTestId('checklist_item.run_command_button')).toBeNull();
});
});

View file

@ -0,0 +1,274 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useMemo, type ComponentProps} from 'react';
import {defineMessages, useIntl, type IntlShape} from 'react-intl';
import {View, Text} from 'react-native';
import MenuDivider from '@components/menu_divider';
import OptionBox from '@components/option_box';
import OptionItem, {ITEM_HEIGHT} from '@components/option_item';
import {useTheme} from '@context/theme';
import {dismissBottomSheet, openUserProfileModal} from '@screens/navigation';
import {toMilliseconds} from '@utils/datetime';
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
import {typography} from '@utils/typography';
import Checkbox from './checkbox';
import type PlaybookChecklistItemModel from '@playbooks/types/database/models/playbook_checklist_item';
import type UserModel from '@typings/database/models/servers/user';
const messages = defineMessages({
check: {
id: 'playbooks.checklist_item.check',
defaultMessage: 'Check',
},
checked: {
id: 'playbooks.checklist_item.checked',
defaultMessage: 'Checked',
},
skip: {
id: 'playbooks.checklist_item.skip',
defaultMessage: 'Skip',
},
skipped: {
id: 'playbooks.checklist_item.skipped',
defaultMessage: 'Skipped',
},
runCommand: {
id: 'playbooks.checklist_item.run_command',
defaultMessage: 'Run command',
},
rerunCommand: {
id: 'playbooks.checklist_item.rerun_command',
defaultMessage: 'Rerun command',
},
assignee: {
id: 'playbooks.checklist_item.assignee',
defaultMessage: 'Assignee',
},
dueDate: {
id: 'playbooks.checklist_item.due_date',
defaultMessage: 'Due date',
},
command: {
id: 'playbooks.checklist_item.command',
defaultMessage: 'Command',
},
none: {
id: 'playbooks.checklist_item.none',
defaultMessage: 'None',
},
dateAtTime: {
id: 'playbooks.checklist_item.date_at_time',
defaultMessage: '{date} at {time}',
},
});
const ACTION_BUTTON_HEIGHT = 62;
const N_OPTIONS = 3;
const OPTIONS_GAP = 8;
const SCROLL_CONTENT_GAP = 12;
const TITLE_LINE_HEIGHT = 24; // From typography 300
const BODY_LINE_HEIGHT = 24; // From typography 200
const BODY_LINES_COUNT = 3;
export const BOTTOM_SHEET_HEIGHT = {
base: (N_OPTIONS * ITEM_HEIGHT) + (OPTIONS_GAP * (N_OPTIONS - 1)) + (SCROLL_CONTENT_GAP * 2) + TITLE_LINE_HEIGHT + (BODY_LINE_HEIGHT * BODY_LINES_COUNT),
actionButtons: ACTION_BUTTON_HEIGHT + SCROLL_CONTENT_GAP,
};
const getStyleSheet = makeStyleSheetFromTheme((theme) => ({
container: {
flex: 1,
backgroundColor: theme.centerChannelBg,
gap: SCROLL_CONTENT_GAP,
},
checkboxContainer: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: 12,
},
taskTitle: {
...typography('Body', 300, 'Regular'),
color: theme.centerChannelColor,
},
taskDescription: {
...typography('Body', 200, 'Regular'),
color: changeOpacity(theme.centerChannelColor, 0.72),
},
actionButtonsContainer: {
flexDirection: 'row',
gap: 12,
height: ACTION_BUTTON_HEIGHT,
},
taskDetailsContainer: {
gap: OPTIONS_GAP,
},
flex: {
flex: 1,
},
}));
type Props = {
item: PlaybookChecklistItemModel | PlaybookChecklistItem;
assignee?: UserModel;
onCheck: () => void;
onSkip: () => void;
onRunCommand: () => void;
teammateNameDisplay: string;
isDisabled: boolean;
};
function getDueDateInfo(intl: IntlShape, dueDate: number | undefined) {
if (!dueDate) {
return intl.formatMessage(messages.none);
}
const dateObject = new Date(dueDate);
const dateString = dateObject.toLocaleDateString(intl.locale, {month: 'long', day: 'numeric', weekday: 'long'});
if (Math.abs(dueDate - Date.now()) < toMilliseconds({days: 1})) {
const timeString = dateObject.toLocaleTimeString(intl.locale, {hour: '2-digit', minute: '2-digit'});
return intl.formatMessage(messages.dateAtTime, {date: dateString, time: timeString});
}
return dateString;
}
const ChecklistItemBottomSheet = ({
item,
assignee,
onCheck,
onSkip,
onRunCommand,
teammateNameDisplay,
isDisabled,
}: Props) => {
const theme = useTheme();
const styles = getStyleSheet(theme);
const intl = useIntl();
const dueDate = 'dueDate' in item ? item.dueDate : item.due_date;
const isChecked = item.state === 'closed';
const isSkipped = item.state === 'skipped';
const isCommandRun = Boolean('commandLastRun' in item ? item.commandLastRun : item.command_last_run);
const handleCheck = useCallback(async () => {
await dismissBottomSheet();
onCheck?.();
}, [onCheck]);
const handleSkip = useCallback(async () => {
await dismissBottomSheet();
onSkip?.();
}, [onSkip]);
const handleRunCommand = useCallback(async () => {
await dismissBottomSheet();
onRunCommand?.();
}, [onRunCommand]);
const renderActionButtons = () => (
<View style={styles.actionButtonsContainer}>
<OptionBox
iconName='check'
activeText={intl.formatMessage(messages.check)}
text={intl.formatMessage(messages.check)}
onPress={handleCheck}
isActive={isChecked}
testID='checklist_item.check_button'
/>
<OptionBox
iconName='close'
activeText={intl.formatMessage(messages.skipped)}
text={intl.formatMessage(messages.skip)}
onPress={handleSkip}
isActive={isSkipped}
testID='checklist_item.skip_button'
/>
{Boolean(item.command) && (
<OptionBox
iconName='slash-forward'
activeText={intl.formatMessage(messages.rerunCommand)}
text={intl.formatMessage(messages.runCommand)}
onPress={handleRunCommand}
isActive={isCommandRun}
testID='checklist_item.run_command_button'
/>
)}
</View>
);
const onUserChipPress = useCallback((userId: string) => {
openUserProfileModal(intl, theme, {
userId,
location: 'PlaybookRun',
});
}, [intl, theme]);
const assigneeInfo: ComponentProps<typeof OptionItem>['info'] = useMemo(() => {
if (!assignee) {
return intl.formatMessage(messages.none);
}
return {
user: assignee,
onPress: onUserChipPress,
teammateNameDisplay,
location: 'PlaybookRun',
};
}, [assignee, intl, onUserChipPress, teammateNameDisplay]);
const renderTaskDetails = () => (
<View style={styles.taskDetailsContainer}>
<OptionItem
type='none'
icon='account-multiple-plus-outline'
label={intl.formatMessage(messages.assignee)}
info={assigneeInfo}
testID='checklist_item.assignee'
/>
<OptionItem
type='none'
icon='calendar-outline'
label={intl.formatMessage(messages.dueDate)}
info={getDueDateInfo(intl, dueDate)}
testID='checklist_item.due_date'
/>
<OptionItem
type='none'
icon='slash-forward'
label={intl.formatMessage(messages.command)}
info={item.command || intl.formatMessage(messages.none)}
testID='checklist_item.command'
longInfo={true}
/>
</View>
);
return (
<View
style={styles.container}
>
<View style={styles.checkboxContainer}>
<Checkbox
checked={isChecked}
onPress={handleCheck}
/>
<View style={styles.flex}>
<Text style={styles.taskTitle}>
{item.title}
</Text>
{Boolean(item.description) && (
<Text style={styles.taskDescription}>
{item.description}
</Text>
)}
</View>
</View>
<MenuDivider/>
{!isDisabled && renderActionButtons()}
{renderTaskDetails()}
</View>
);
};
export default ChecklistItemBottomSheet;

View file

@ -1,9 +1,9 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import BottomSheetM, {BottomSheetBackdrop, BottomSheetView, type BottomSheetBackdropProps} from '@gorhom/bottom-sheet';
import BottomSheetM, {BottomSheetBackdrop, BottomSheetScrollView, BottomSheetView, type BottomSheetBackdropProps} from '@gorhom/bottom-sheet';
import React, {type ReactNode, useCallback, useEffect, useMemo, useRef} from 'react';
import {DeviceEventEmitter, type Handle, InteractionManager, Keyboard, type StyleProp, View, type ViewStyle} from 'react-native';
import {DeviceEventEmitter, type Handle, InteractionManager, Keyboard, ScrollView, type StyleProp, View, type ViewStyle} from 'react-native';
import {ReduceMotion, useReducedMotion, type WithSpringConfig} from 'react-native-reanimated';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
@ -36,6 +36,7 @@ type Props = {
snapPoints?: Array<string | number>;
enableDynamicSizing?: boolean;
testID?: string;
scrollable?: boolean;
}
const PADDING_TOP_MOBILE = 20;
@ -98,6 +99,7 @@ const BottomSheet = ({
snapPoints = [1, '50%', '80%'],
testID,
enableDynamicSizing = false,
scrollable = false,
}: Props) => {
const reducedMotion = useReducedMotion();
const sheetRef = useRef<BottomSheetM>(null);
@ -150,7 +152,7 @@ const BottomSheet = ({
} else {
close();
}
}, []);
}, [close]);
const handleChange = useCallback((index: number) => {
timeoutRef.current = setTimeout(() => {
@ -163,7 +165,7 @@ const BottomSheet = ({
if (index <= 0) {
close();
}
}, []);
}, [close]);
useAndroidHardwareBackHandler(componentId, handleClose);
useNavButtonPressed(closeButtonId || '', componentId, close, [close]);
@ -217,6 +219,25 @@ const BottomSheet = ({
);
}
let content;
if (scrollable) {
const Scroll = isTablet ? ScrollView : BottomSheetScrollView;
content = (
<Scroll
style={styles.view}
showsVerticalScrollIndicator={false}
>
{renderContainerContent()}
</Scroll>
);
} else {
content = (
<BottomSheetView style={styles.view}>
{renderContainerContent()}
</BottomSheetView>
);
}
return (
<BottomSheetM
ref={sheetRef}
@ -237,9 +258,7 @@ const BottomSheet = ({
bottomInset={insets.bottom}
enableDynamicSizing={enableDynamicSizing}
>
<BottomSheetView style={styles.view}>
{renderContainerContent()}
</BottomSheetView>
{content}
</BottomSheetM>
);
};

View file

@ -807,9 +807,10 @@ type BottomSheetArgs = {
snapPoints: Array<number | string>;
theme: Theme;
title: string;
scrollable?: boolean;
}
export function bottomSheet({title, renderContent, footerComponent, snapPoints, initialSnapIndex = 1, theme, closeButtonId}: BottomSheetArgs) {
export function bottomSheet({title, renderContent, footerComponent, snapPoints, initialSnapIndex = 1, theme, closeButtonId, scrollable = false}: BottomSheetArgs) {
if (isTablet()) {
showModal(Screens.BOTTOM_SHEET, title, {
closeButtonId,
@ -817,6 +818,7 @@ export function bottomSheet({title, renderContent, footerComponent, snapPoints,
renderContent,
footerComponent,
snapPoints,
scrollable,
}, bottomSheetModalOptions(theme, closeButtonId));
} else {
showModalOverCurrentContext(Screens.BOTTOM_SHEET, {
@ -824,6 +826,7 @@ export function bottomSheet({title, renderContent, footerComponent, snapPoints,
renderContent,
footerComponent,
snapPoints,
scrollable,
}, bottomSheetModalOptions(theme));
}
}

View file

@ -978,11 +978,23 @@
"pinned_messages.empty.title": "No pinned messages yet",
"playbook_run.checklist.dueIn": "Due {dueDate}",
"playbook_run.checklist.rerunCommand": "{command} (Rerun)",
"playbook_run.checklist.taskDetails": "Task Details",
"playbook_run.out_of_date_header.message": "Unable to connect to server. Content may be out of date. Last updated {lastUpdated}.",
"playbook.last_updated": "Last update {date}",
"playbook.participants": "Run Participants",
"playbook.runs.finished": "Finished",
"playbook.runs.in-progress": "In Progress",
"playbooks.checklist_item.assignee": "Assignee",
"playbooks.checklist_item.check": "Check",
"playbooks.checklist_item.checked": "Checked",
"playbooks.checklist_item.command": "Command",
"playbooks.checklist_item.date_at_time": "{date} at {time}",
"playbooks.checklist_item.due_date": "Due date",
"playbooks.checklist_item.none": "None",
"playbooks.checklist_item.rerun_command": "Rerun command",
"playbooks.checklist_item.run_command": "Run command",
"playbooks.checklist_item.skip": "Skip",
"playbooks.checklist_item.skipped": "Skipped",
"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",

View file

@ -426,6 +426,7 @@ jest.mock('@screens/navigation', () => ({
dismissBottomSheet: jest.fn(),
openUserProfileModal: jest.fn(),
popTo: jest.fn(),
bottomSheet: jest.fn(),
}));
jest.mock('@mattermost/react-native-emm', () => ({