From 8aa13c4e0ac43903d9daddb238dabc01e79d916e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Espino=20Garc=C3=ADa?= Date: Mon, 25 Aug 2025 12:37:29 +0200 Subject: [PATCH] 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 --- app/components/option_item/index.tsx | 17 +- .../actions/remote/checklist.test.ts | 75 +++- .../playbooks/actions/remote/checklist.ts | 47 ++- app/products/playbooks/client/rest.test.ts | 81 ++++ app/products/playbooks/client/rest.ts | 25 +- .../checklist_item/checklist_item.test.tsx | 53 ++- .../checklist_item/checklist_item.tsx | 63 ++- .../checklist_item_bottom_sheet.test.tsx | 395 ++++++++++++++++++ .../checklist_item_bottom_sheet.tsx | 274 ++++++++++++ app/screens/bottom_sheet/index.tsx | 33 +- app/screens/navigation.ts | 5 +- assets/base/i18n/en.json | 12 + test/setup.ts | 1 + 13 files changed, 1035 insertions(+), 46 deletions(-) create mode 100644 app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet.test.tsx create mode 100644 app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet.tsx diff --git a/app/components/option_item/index.tsx b/app/components/option_item/index.tsx index 1f0514a0b..fcc447754 100644 --- a/app/components/option_item/index.tsx +++ b/app/components/option_item/index.tsx @@ -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} ); + if (actionComponent) { + // Wrap the text into another view to properly calculate + // the space available. + infoComponent = ( + + {infoComponent} + + ); + } } const component = ( diff --git a/app/products/playbooks/actions/remote/checklist.test.ts b/app/products/playbooks/actions/remote/checklist.test.ts index 4a71516e1..0fbad9c9e 100644 --- a/app/products/playbooks/actions/remote/checklist.test.ts +++ b/app/products/playbooks/actions/remote/checklist.test.ts @@ -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, ''); + }); + }); }); diff --git a/app/products/playbooks/actions/remote/checklist.ts b/app/products/playbooks/actions/remote/checklist.ts index 480d9eb51..476d75f9a 100644 --- a/app/products/playbooks/actions/remote/checklist.ts +++ b/app/products/playbooks/actions/remote/checklist.ts @@ -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}; + } +}; diff --git a/app/products/playbooks/client/rest.test.ts b/app/products/playbooks/client/rest.test.ts index 75b48763b..bb9b284f4 100644 --- a/app/products/playbooks/client/rest.test.ts +++ b/app/products/playbooks/client/rest.test.ts @@ -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'); + }); +}); + diff --git a/app/products/playbooks/client/rest.ts b/app/products/playbooks/client/rest.ts index c3f7f657a..597c24f11 100644 --- a/app/products/playbooks/client/rest.ts +++ b/app/products/playbooks/client/rest.ts @@ -9,16 +9,16 @@ export interface ClientPlaybooksMix { // Playbook Runs fetchPlaybookRuns: (params: FetchPlaybookRunsParams, groupLabel?: RequestGroupLabel) => Promise; - fetchPlaybookRun: (id: string, groupLabel?: RequestGroupLabel) => Promise; // Run Management // finishRun: (playbookRunId: string) => Promise; // Checklist Management - setChecklistItemState: (playbookRunID: string, checklistNum: number, itemNum: number, newState: ChecklistItemState) => Promise; + setChecklistItemState: (playbookRunID: string, checklistNum: number, itemNum: number, newState: ChecklistItemState) => Promise; + skipChecklistItem: (playbookRunID: string, checklistNum: number, itemNum: number) => Promise; + restoreChecklistItem: (playbookRunID: string, checklistNum: number, itemNum: number) => Promise; - // skipChecklistItem: (playbookRunID: string, checklistNum: number, itemNum: number) => Promise; // skipChecklist: (playbookRunID: string, checklistNum: number) => Promise; // Slash Commands @@ -85,12 +85,19 @@ const ClientPlaybooks = >(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( diff --git a/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item.test.tsx b/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item.test.tsx index 458795a88..af91ddeb9 100644 --- a/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item.test.tsx +++ b/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item.test.tsx @@ -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(); + + const item = getByText('Test Item'); + act(() => { + fireEvent.press(item); + }); + + expect(bottomSheet).toHaveBeenCalled(); + const args = jest.mocked(bottomSheet).mock.calls[0][0] as Parameters[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(); + 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); + }); + }); }); diff --git a/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item.tsx b/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item.tsx index a07e5e2e7..20b6607a5 100644 --- a/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item.tsx +++ b/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item.tsx @@ -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(() => ( + + ), [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 ( {checkbox} - - {item.title} - {item.description && ( - {item.description} - )} - + + + {item.title} + {Boolean(item.description) && ( + {item.description} + )} + + {(assignee || dueDate || (item.command)) && ( @@ -210,7 +255,7 @@ const ChecklistItem = ({ /> )} - {item.command && ( + {Boolean(item.command) && ( ({ + useIsTablet: jest.fn(), +})); + +jest.mock('@gorhom/bottom-sheet', () => ({ + BottomSheetScrollView: jest.fn(), +})); +jest.mocked(BottomSheetScrollView).mockImplementation((props: any) => ); // 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 { + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + expect(queryByTestId('checklist_item.run_command_button')).toBeNull(); + }); +}); diff --git a/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet.tsx b/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet.tsx new file mode 100644 index 000000000..d74dd6f09 --- /dev/null +++ b/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet.tsx @@ -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 = () => ( + + + + {Boolean(item.command) && ( + + )} + + ); + + const onUserChipPress = useCallback((userId: string) => { + openUserProfileModal(intl, theme, { + userId, + location: 'PlaybookRun', + }); + }, [intl, theme]); + + const assigneeInfo: ComponentProps['info'] = useMemo(() => { + if (!assignee) { + return intl.formatMessage(messages.none); + } + return { + user: assignee, + onPress: onUserChipPress, + teammateNameDisplay, + location: 'PlaybookRun', + }; + }, [assignee, intl, onUserChipPress, teammateNameDisplay]); + + const renderTaskDetails = () => ( + + + + + + ); + + return ( + + + + + + {item.title} + + {Boolean(item.description) && ( + + {item.description} + + )} + + + + {!isDisabled && renderActionButtons()} + {renderTaskDetails()} + + ); +}; + +export default ChecklistItemBottomSheet; diff --git a/app/screens/bottom_sheet/index.tsx b/app/screens/bottom_sheet/index.tsx index b6ea42eda..de8cdd072 100644 --- a/app/screens/bottom_sheet/index.tsx +++ b/app/screens/bottom_sheet/index.tsx @@ -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; 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(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 = ( + + {renderContainerContent()} + + ); + } else { + content = ( + + {renderContainerContent()} + + ); + } + return ( - - {renderContainerContent()} - + {content} ); }; diff --git a/app/screens/navigation.ts b/app/screens/navigation.ts index f4c8210c4..2fd6de665 100644 --- a/app/screens/navigation.ts +++ b/app/screens/navigation.ts @@ -807,9 +807,10 @@ type BottomSheetArgs = { snapPoints: Array; 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)); } } diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index a7aad817c..5f962ce92 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -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", diff --git a/test/setup.ts b/test/setup.ts index 23204381e..5f18bb291 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -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', () => ({