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:
parent
0c4a42a06a
commit
8aa13c4e0a
13 changed files with 1035 additions and 46 deletions
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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, '');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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', () => ({
|
||||
|
|
|
|||
Loading…
Reference in a new issue