From b227837fa5da0be4fc298704ef288c35ce5225c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Espino=20Garc=C3=ADa?= Date: Mon, 8 Sep 2025 14:29:15 +0200 Subject: [PATCH] Add playbooks edit command functionality (#9084) * Add playbooks edit command functionality * Fix tests * Add tests * Remove option to change command when the run is disabled (not participant or finished) * Fix tests * Address feedback * Fix test * Remove unneeded variables --- .../autocomplete/autocomplete.test.tsx | 123 ++++++++++ app/components/autocomplete/autocomplete.tsx | 74 +++--- app/constants/screens.ts | 7 +- .../playbooks/actions/local/checklist.test.ts | 58 ++++- .../playbooks/actions/local/checklist.ts | 21 ++ .../actions/remote/checklist.test.ts | 65 +++++- .../playbooks/actions/remote/checklist.ts | 29 ++- app/products/playbooks/client/rest.test.ts | 40 +++- app/products/playbooks/client/rest.ts | 16 +- app/products/playbooks/constants/screens.ts | 12 + .../edit_command/edit_command.test.tsx | 171 ++++++++++++++ .../screens/edit_command/edit_command.tsx | 99 ++++++++ .../edit_command/edit_command_form.test.tsx | 126 +++++++++++ .../edit_command/edit_command_form.tsx | 122 ++++++++++ .../playbooks/screens/edit_command/index.tsx | 4 + app/products/playbooks/screens/index.test.tsx | 66 ++++++ app/products/playbooks/screens/index.tsx | 18 ++ .../checklist_item/checklist_item.test.tsx | 38 +++- .../checklist_item/checklist_item.tsx | 35 ++- .../checklist_item_bottom_sheet.test.tsx | 72 +++++- .../checklist_item_bottom_sheet.tsx | 34 ++- .../index.test.tsx | 213 ++++++++++++++++++ .../checklist_item_bottom_sheet/index.ts | 48 ++++ .../checklist/checklist_item/index.test.tsx | 58 +++++ .../checklist/checklist_item/index.ts | 12 +- app/screens/index.test.tsx | 20 ++ app/screens/index.tsx | 16 +- assets/base/i18n/en.json | 4 + 28 files changed, 1539 insertions(+), 62 deletions(-) create mode 100644 app/components/autocomplete/autocomplete.test.tsx create mode 100644 app/products/playbooks/constants/screens.ts create mode 100644 app/products/playbooks/screens/edit_command/edit_command.test.tsx create mode 100644 app/products/playbooks/screens/edit_command/edit_command.tsx create mode 100644 app/products/playbooks/screens/edit_command/edit_command_form.test.tsx create mode 100644 app/products/playbooks/screens/edit_command/edit_command_form.tsx create mode 100644 app/products/playbooks/screens/edit_command/index.tsx create mode 100644 app/products/playbooks/screens/index.test.tsx create mode 100644 app/products/playbooks/screens/index.tsx rename app/products/playbooks/screens/playbook_run/checklist/checklist_item/{ => checklist_item_bottom_sheet}/checklist_item_bottom_sheet.test.tsx (86%) rename app/products/playbooks/screens/playbook_run/checklist/checklist_item/{ => checklist_item_bottom_sheet}/checklist_item_bottom_sheet.tsx (87%) create mode 100644 app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet/index.test.tsx create mode 100644 app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet/index.ts diff --git a/app/components/autocomplete/autocomplete.test.tsx b/app/components/autocomplete/autocomplete.test.tsx new file mode 100644 index 000000000..5c7b50769 --- /dev/null +++ b/app/components/autocomplete/autocomplete.test.tsx @@ -0,0 +1,123 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {type ComponentProps} from 'react'; + +import {render} from '@test/intl-test-helper'; + +import AtMention from './at_mention/'; +import Autocomplete from './autocomplete'; +import ChannelMention from './channel_mention/'; +import EmojiSuggestion from './emoji_suggestion/'; +import SlashSuggestion from './slash_suggestion/'; +import AppSlashSuggestion from './slash_suggestion/app_slash_suggestion/'; + +import type {SharedValue} from 'react-native-reanimated'; + +jest.mock('./at_mention/'); +jest.mocked(AtMention).mockImplementation((props) => React.createElement('AtMention', {...props, testID: 'at-mention-mock'})); + +jest.mock('./channel_mention/'); +jest.mocked(ChannelMention).mockImplementation((props) => React.createElement('ChannelMention', {...props, testID: 'channel-mention-mock'})); + +jest.mock('./emoji_suggestion/'); +jest.mocked(EmojiSuggestion).mockImplementation((props) => React.createElement('EmojiSuggestion', {...props, testID: 'emoji-suggestion-mock'})); + +jest.mock('./slash_suggestion/'); +jest.mocked(SlashSuggestion).mockImplementation((props) => React.createElement('SlashSuggestion', {...props, testID: 'slash-suggestion-mock'})); + +jest.mock('./slash_suggestion/app_slash_suggestion/'); +jest.mocked(AppSlashSuggestion).mockImplementation((props) => React.createElement('AppSlashSuggestion', {...props, testID: 'app-slash-suggestion-mock'})); + +describe('Autocomplete', () => { + function getBaseProps(): ComponentProps { + return { + availableSpace: {value: 0} as SharedValue, + cursorPosition: 0, + isAppsEnabled: true, + position: {value: 0} as SharedValue, + updateValue: jest.fn(), + value: '', + autocompleteProviders: { + channel: true, + emoji: true, + slash: true, + user: true, + }, + }; + } + it('should only render the selected autocomplete providers', () => { + const props = getBaseProps(); + props.autocompleteProviders = { + user: true, + channel: true, + emoji: true, + slash: true, + }; + props.channelId = 'channel-id'; // required for slash suggestions + + const {getByTestId, queryByTestId, rerender} = render(); + + expect(getByTestId('at-mention-mock')).toBeTruthy(); + expect(getByTestId('channel-mention-mock')).toBeTruthy(); + expect(getByTestId('emoji-suggestion-mock')).toBeTruthy(); + expect(getByTestId('slash-suggestion-mock')).toBeTruthy(); + expect(getByTestId('app-slash-suggestion-mock')).toBeTruthy(); + + props.autocompleteProviders = { + user: true, + channel: true, + emoji: true, + slash: false, + }; + rerender(); + + expect(getByTestId('at-mention-mock')).toBeTruthy(); + expect(getByTestId('channel-mention-mock')).toBeTruthy(); + expect(getByTestId('emoji-suggestion-mock')).toBeTruthy(); + expect(queryByTestId('slash-suggestion-mock')).toBeNull(); + expect(queryByTestId('app-slash-suggestion-mock')).toBeNull(); + + props.autocompleteProviders = { + user: false, + channel: true, + emoji: true, + slash: true, + }; + rerender(); + + expect(queryByTestId('at-mention-mock')).toBeNull(); + expect(getByTestId('channel-mention-mock')).toBeTruthy(); + expect(getByTestId('emoji-suggestion-mock')).toBeTruthy(); + expect(getByTestId('slash-suggestion-mock')).toBeTruthy(); + expect(getByTestId('app-slash-suggestion-mock')).toBeTruthy(); + + props.autocompleteProviders = { + user: true, + channel: false, + emoji: true, + slash: true, + }; + rerender(); + + expect(getByTestId('at-mention-mock')).toBeTruthy(); + expect(queryByTestId('channel-mention-mock')).toBeNull(); + expect(getByTestId('emoji-suggestion-mock')).toBeTruthy(); + expect(getByTestId('slash-suggestion-mock')).toBeTruthy(); + expect(getByTestId('app-slash-suggestion-mock')).toBeTruthy(); + + props.autocompleteProviders = { + user: true, + channel: true, + emoji: false, + slash: true, + }; + rerender(); + + expect(getByTestId('at-mention-mock')).toBeTruthy(); + expect(getByTestId('channel-mention-mock')).toBeTruthy(); + expect(queryByTestId('emoji-suggestion-mock')).toBeNull(); + expect(getByTestId('slash-suggestion-mock')).toBeTruthy(); + expect(getByTestId('app-slash-suggestion-mock')).toBeTruthy(); + }); +}); diff --git a/app/components/autocomplete/autocomplete.tsx b/app/components/autocomplete/autocomplete.tsx index c6d3fef36..31939af21 100644 --- a/app/components/autocomplete/autocomplete.tsx +++ b/app/components/autocomplete/autocomplete.tsx @@ -64,8 +64,23 @@ type Props = { growDown?: boolean; teamId?: string; containerStyle?: StyleProp; + autocompleteProviders?: AutocompleteProviders; } +type AutocompleteProviders = { + user: boolean; + channel: boolean; + emoji: boolean; + slash: boolean; +} + +const defaultAutocompleteProviders: AutocompleteProviders = { + user: true, + channel: true, + emoji: true, + slash: true, +}; + const Autocomplete = ({ cursorPosition, position, @@ -83,6 +98,7 @@ const Autocomplete = ({ growDown = false, containerStyle, teamId, + autocompleteProviders = defaultAutocompleteProviders, }: Props) => { const theme = useTheme(); const isTablet = useIsTablet(); @@ -133,7 +149,7 @@ const Autocomplete = ({ testID='autocomplete' style={containerStyles} > - {isAppsEnabled && channelId && ( + {isAppsEnabled && channelId && autocompleteProviders.slash && ( )} {(!appsTakeOver || !isAppsEnabled) && (<> - - - {!isSearch && + {autocompleteProviders.user && ( + + )} + {autocompleteProviders.channel && ( + + )} + {!isSearch && autocompleteProviders.emoji && ( - } - {showCommands && channelId && + )} + {showCommands && channelId && autocompleteProviders.slash && ( - } + )} {/* {(isSearch && enableDateSuggestion) && ([ diff --git a/app/products/playbooks/actions/local/checklist.test.ts b/app/products/playbooks/actions/local/checklist.test.ts index 98f14a5a2..009c74775 100644 --- a/app/products/playbooks/actions/local/checklist.test.ts +++ b/app/products/playbooks/actions/local/checklist.test.ts @@ -5,7 +5,7 @@ import DatabaseManager from '@database/manager'; import {getPlaybookChecklistItemById} from '@playbooks/database/queries/item'; import TestHelper from '@test/test_helper'; -import {updateChecklistItem} from './checklist'; +import {updateChecklistItem, setChecklistItemCommand} from './checklist'; import type ServerDataOperator from '@database/operator/server_data_operator'; @@ -68,3 +68,59 @@ describe('updateChecklistItem', () => { await Promise.all(testPromises); }); }); + +describe('setChecklistItemCommand', () => { + it('should handle not found database', async () => { + const {error} = await setChecklistItemCommand('foo', 'itemid', '/test command'); + expect(error).toBeTruthy(); + expect((error as Error).message).toContain('foo database not found'); + }); + + it('should handle item not found', async () => { + const {error} = await setChecklistItemCommand(serverUrl, 'nonexistent', '/test command'); + expect(error).toBe('Item not found: nonexistent'); + }); + + it('should handle database write errors', async () => { + const checklistId = 'checklistid'; + const item = TestHelper.createPlaybookItem(checklistId, 0); + await operator.handlePlaybookChecklistItem({items: [{...item, checklist_id: checklistId}], prepareRecordsOnly: false}); + + const originalWrite = operator.database.write; + operator.database.write = jest.fn().mockRejectedValue(new Error('Database write failed')); + + const {error} = await setChecklistItemCommand(serverUrl, item.id, '/test command'); + expect(error).toBeTruthy(); + + operator.database.write = originalWrite; + }); + + it('should set command successfully', async () => { + const checklistId = 'checklistid'; + const item = TestHelper.createPlaybookItem(checklistId, 0); + await operator.handlePlaybookChecklistItem({items: [{...item, checklist_id: checklistId}], prepareRecordsOnly: false}); + + const testCommand = '/test command with parameters'; + const {data, error} = await setChecklistItemCommand(serverUrl, item.id, testCommand); + expect(error).toBeUndefined(); + expect(data).toBe(true); + + const updated = await getPlaybookChecklistItemById(operator.database, item.id); + expect(updated).toBeDefined(); + expect(updated!.command).toBe(testCommand); + }); + + it('should handle empty command string', async () => { + const checklistId = 'checklistid'; + const item = TestHelper.createPlaybookItem(checklistId, 0); + await operator.handlePlaybookChecklistItem({items: [{...item, checklist_id: checklistId}], prepareRecordsOnly: false}); + + const {data, error} = await setChecklistItemCommand(serverUrl, item.id, ''); + expect(error).toBeUndefined(); + expect(data).toBe(true); + + const updated = await getPlaybookChecklistItemById(operator.database, item.id); + expect(updated).toBeDefined(); + expect(updated!.command).toBe(''); + }); +}); diff --git a/app/products/playbooks/actions/local/checklist.ts b/app/products/playbooks/actions/local/checklist.ts index 03af3ff03..ef5da60dd 100644 --- a/app/products/playbooks/actions/local/checklist.ts +++ b/app/products/playbooks/actions/local/checklist.ts @@ -25,3 +25,24 @@ export async function updateChecklistItem(serverUrl: string, itemId: string, sta return {error}; } } + +export async function setChecklistItemCommand(serverUrl: string, itemId: string, command: string) { + try { + const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const item = await getPlaybookChecklistItemById(database, itemId); + if (!item) { + return {error: `Item not found: ${itemId}`}; + } + + await database.write(async () => { + item.update((i) => { + i.command = command; + }); + }); + + return {data: true}; + } catch (error) { + logError('failed to set checklist item command', error); + return {error}; + } +} diff --git a/app/products/playbooks/actions/remote/checklist.test.ts b/app/products/playbooks/actions/remote/checklist.test.ts index 0fbad9c9e..261b888b3 100644 --- a/app/products/playbooks/actions/remote/checklist.test.ts +++ b/app/products/playbooks/actions/remote/checklist.test.ts @@ -2,10 +2,11 @@ // See LICENSE.txt for license information. import DatabaseManager from '@database/manager'; +import IntegrationsManager from '@managers/integrations_manager'; import NetworkManager from '@managers/network_manager'; -import {updateChecklistItem as localUpdateChecklistItem} from '@playbooks/actions/local/checklist'; +import {setChecklistItemCommand as localSetChecklistItemCommand, updateChecklistItem as localUpdateChecklistItem} from '@playbooks/actions/local/checklist'; -import {updateChecklistItem, runChecklistItem, skipChecklistItem, restoreChecklistItem} from './checklist'; +import {updateChecklistItem, runChecklistItem, skipChecklistItem, restoreChecklistItem, setChecklistItemCommand} from './checklist'; const serverUrl = 'baseHandler.test.com'; @@ -13,12 +14,14 @@ const playbookRunId = 'playbook-run-id-1'; const itemId = 'checklist-item-id-1'; const checklistNumber = 0; const itemNumber = 1; +const command = '/test-command'; const mockClient = { setChecklistItemState: jest.fn(), runChecklistItemSlashCommand: jest.fn(), skipChecklistItem: jest.fn(), restoreChecklistItem: jest.fn(), + setChecklistItemCommand: jest.fn(), }; jest.mock('@playbooks/actions/local/checklist'); @@ -89,6 +92,32 @@ describe('checklist', () => { expect(result.data).toBe(true); expect(mockClient.runChecklistItemSlashCommand).toHaveBeenCalledWith(playbookRunId, checklistNumber, itemNumber); }); + + it('should set trigger id if it is returned', async () => { + mockClient.runChecklistItemSlashCommand.mockResolvedValueOnce({trigger_id: 'trigger_id'}); + const setTriggerId = jest.fn(); + jest.spyOn(IntegrationsManager, 'getManager').mockReturnValue({ + setTriggerId, + } as any); + + const result = await runChecklistItem(serverUrl, playbookRunId, checklistNumber, itemNumber); + expect(result).toBeDefined(); + expect(result.error).toBeUndefined(); + expect(setTriggerId).toHaveBeenCalledWith('trigger_id'); + }); + + it('should not set trigger id if it is not returned', async () => { + mockClient.runChecklistItemSlashCommand.mockResolvedValueOnce({}); + const setTriggerId = jest.fn(); + jest.spyOn(IntegrationsManager, 'getManager').mockReturnValue({ + setTriggerId, + } as any); + + const result = await runChecklistItem(serverUrl, playbookRunId, checklistNumber, itemNumber); + expect(result).toBeDefined(); + expect(result.error).toBeUndefined(); + expect(setTriggerId).not.toHaveBeenCalled(); + }); }); describe('skipChecklistItem', () => { @@ -152,4 +181,36 @@ describe('checklist', () => { expect(localUpdateChecklistItem).toHaveBeenCalledWith(serverUrl, itemId, ''); }); }); + + describe('setChecklistItemCommand', () => { + it('should handle client error', async () => { + jest.spyOn(NetworkManager, 'getClient').mockImplementationOnce(throwFunc); + + const result = await setChecklistItemCommand(serverUrl, playbookRunId, itemId, checklistNumber, itemNumber, command); + expect(result).toBeDefined(); + expect(result.error).toBeDefined(); + expect(localSetChecklistItemCommand).not.toHaveBeenCalled(); + }); + + it('should handle API exception', async () => { + mockClient.setChecklistItemCommand.mockImplementationOnce(throwFunc); + + const result = await setChecklistItemCommand(serverUrl, playbookRunId, itemId, checklistNumber, itemNumber, command); + expect(result).toBeDefined(); + expect(result.error).toBeDefined(); + expect(mockClient.setChecklistItemCommand).toHaveBeenCalledWith(playbookRunId, checklistNumber, itemNumber, command); + expect(localSetChecklistItemCommand).not.toHaveBeenCalled(); + }); + + it('should set checklist item command successfully', async () => { + mockClient.setChecklistItemCommand.mockResolvedValueOnce({}); + + const result = await setChecklistItemCommand(serverUrl, playbookRunId, itemId, checklistNumber, itemNumber, command); + expect(result).toBeDefined(); + expect(result.error).toBeUndefined(); + expect(result.data).toBe(true); + expect(mockClient.setChecklistItemCommand).toHaveBeenCalledWith(playbookRunId, checklistNumber, itemNumber, command); + expect(localSetChecklistItemCommand).toHaveBeenCalledWith(serverUrl, itemId, command); + }); + }); }); diff --git a/app/products/playbooks/actions/remote/checklist.ts b/app/products/playbooks/actions/remote/checklist.ts index 476d75f9a..c04d6faa4 100644 --- a/app/products/playbooks/actions/remote/checklist.ts +++ b/app/products/playbooks/actions/remote/checklist.ts @@ -2,8 +2,9 @@ // See LICENSE.txt for license information. import {forceLogoutIfNecessary} from '@actions/remote/session'; +import IntegrationsManager from '@managers/integrations_manager'; import NetworkManager from '@managers/network_manager'; -import {updateChecklistItem as localUpdateChecklistItem} from '@playbooks/actions/local/checklist'; +import {setChecklistItemCommand as localSetChecklistItemCommand, updateChecklistItem as localUpdateChecklistItem} from '@playbooks/actions/local/checklist'; import {getFullErrorMessage} from '@utils/errors'; import {logDebug} from '@utils/log'; @@ -36,7 +37,10 @@ export const runChecklistItem = async ( ) => { try { const client = NetworkManager.getClient(serverUrl); - await client.runChecklistItemSlashCommand(playbookRunId, checklistNumber, itemNumber); + const {trigger_id} = await client.runChecklistItemSlashCommand(playbookRunId, checklistNumber, itemNumber); + if (trigger_id) { + IntegrationsManager.getManager(serverUrl)?.setTriggerId(trigger_id); + } return {data: true}; } catch (error) { logDebug('error on runChecklistItem', getFullErrorMessage(error)); @@ -84,3 +88,24 @@ export const restoreChecklistItem = async ( return {error}; } }; + +export const setChecklistItemCommand = async ( + serverUrl: string, + playbookRunId: string, + itemId: string, + checklistNumber: number, + itemNumber: number, + command: string, +) => { + try { + const client = NetworkManager.getClient(serverUrl); + await client.setChecklistItemCommand(playbookRunId, checklistNumber, itemNumber, command); + + await localSetChecklistItemCommand(serverUrl, itemId, command); + return {data: true}; + } catch (error) { + logDebug('error on setChecklistItemCommand', 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 bb9b284f4..cb68336ae 100644 --- a/app/products/playbooks/client/rest.test.ts +++ b/app/products/playbooks/client/rest.test.ts @@ -141,9 +141,10 @@ describe('runChecklistItemSlashCommand', () => { const expectedUrl = `/plugins/playbooks/api/v0/runs/${playbookRunId}/checklists/${checklistNumber}/item/${itemNumber}/run`; const expectedOptions = {method: 'post'}; - jest.mocked(client.doFetch).mockResolvedValue(undefined); + jest.mocked(client.doFetch).mockResolvedValue({trigger_id: 'trigger123'}); - await client.runChecklistItemSlashCommand(playbookRunId, checklistNumber, itemNumber); + const result = await client.runChecklistItemSlashCommand(playbookRunId, checklistNumber, itemNumber); + expect(result).toEqual({trigger_id: 'trigger123'}); expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions); }); @@ -155,9 +156,10 @@ describe('runChecklistItemSlashCommand', () => { const expectedUrl = `/plugins/playbooks/api/v0/runs/${playbookRunId}/checklists/${checklistNumber}/item/${itemNumber}/run`; const expectedOptions = {method: 'post'}; - jest.mocked(client.doFetch).mockResolvedValue(undefined); + jest.mocked(client.doFetch).mockResolvedValue({trigger_id: ''}); - await client.runChecklistItemSlashCommand(playbookRunId, checklistNumber, itemNumber); + const result = await client.runChecklistItemSlashCommand(playbookRunId, checklistNumber, itemNumber); + expect(result).toEqual({trigger_id: ''}); expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions); }); @@ -253,3 +255,33 @@ describe('restoreChecklistItem', () => { }); }); +describe('setChecklistItemCommand', () => { + test('should set checklist item command successfully', async () => { + const playbookRunID = 'run123'; + const checklistNum = 1; + const itemNum = 2; + const command = '/test command'; + const expectedUrl = `/plugins/playbooks/api/v0/runs/${playbookRunID}/checklists/${checklistNum}/item/${itemNum}/command`; + const expectedOptions = {method: 'put', body: {command}}; + const mockResponse = {success: true}; + + jest.mocked(client.doFetch).mockResolvedValue(mockResponse); + + const result = await client.setChecklistItemCommand(playbookRunID, checklistNum, itemNum, command); + + expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions); + expect(result).toEqual(mockResponse); + }); + + test('should handle error when setting checklist item command', async () => { + const playbookRunID = 'run123'; + const checklistNum = 1; + const itemNum = 2; + const command = '/test command'; + + jest.mocked(client.doFetch).mockRejectedValue(new Error('Network error')); + + await expect(client.setChecklistItemCommand(playbookRunID, checklistNum, itemNum, command)).rejects.toThrow('Network error'); + }); +}); + diff --git a/app/products/playbooks/client/rest.ts b/app/products/playbooks/client/rest.ts index 597c24f11..86b5d8034 100644 --- a/app/products/playbooks/client/rest.ts +++ b/app/products/playbooks/client/rest.ts @@ -22,7 +22,8 @@ export interface ClientPlaybooksMix { // skipChecklist: (playbookRunID: string, checklistNum: number) => Promise; // Slash Commands - runChecklistItemSlashCommand: (playbookRunId: string, checklistNumber: number, itemNumber: number) => Promise; + runChecklistItemSlashCommand: (playbookRunId: string, checklistNumber: number, itemNumber: number) => Promise<{trigger_id: string}>; + setChecklistItemCommand: (playbookRunID: string, checklistNum: number, itemNum: number, command: string) => Promise; } const ClientPlaybooks = >(superclass: TBase) => class extends superclass { @@ -108,10 +109,21 @@ const ClientPlaybooks = >(superclass: TBas // Slash Commands runChecklistItemSlashCommand = async (playbookRunId: string, checklistNumber: number, itemNumber: number) => { - await this.doFetch( + const data = await this.doFetch( `${this.getPlaybookRunRoute(playbookRunId)}/checklists/${checklistNumber}/item/${itemNumber}/run`, {method: 'post'}, ); + + return data; + }; + + setChecklistItemCommand = async (playbookRunID: string, checklistNum: number, itemNum: number, command: string) => { + const data = await this.doFetch( + `${this.getPlaybookRunRoute(playbookRunID)}/checklists/${checklistNum}/item/${itemNum}/command`, + {method: 'put', body: {command}}, + ); + + return data; }; }; diff --git a/app/products/playbooks/constants/screens.ts b/app/products/playbooks/constants/screens.ts new file mode 100644 index 000000000..7bd4ebdb7 --- /dev/null +++ b/app/products/playbooks/constants/screens.ts @@ -0,0 +1,12 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export const PLAYBOOKS_RUNS = 'PlaybookRuns'; +export const PLAYBOOK_RUN = 'PlaybookRun'; +export const PLAYBOOK_EDIT_COMMAND = 'PlaybookEditCommand'; + +export default { + PLAYBOOKS_RUNS, + PLAYBOOK_RUN, + PLAYBOOK_EDIT_COMMAND, +}; diff --git a/app/products/playbooks/screens/edit_command/edit_command.test.tsx b/app/products/playbooks/screens/edit_command/edit_command.test.tsx new file mode 100644 index 000000000..fa9de0c24 --- /dev/null +++ b/app/products/playbooks/screens/edit_command/edit_command.test.tsx @@ -0,0 +1,171 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {type ComponentProps} from 'react'; +import {Keyboard} from 'react-native'; + +import DatabaseManager from '@database/manager'; +import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; +import useNavButtonPressed from '@hooks/navigation_button_pressed'; +import {popTopScreen, setButtons} from '@screens/navigation'; +import {act, renderWithEverything} from '@test/intl-test-helper'; + +import EditCommand from './edit_command'; +import EditCommandForm from './edit_command_form'; + +import type {Database} from '@nozbe/watermelondb'; + +// Mock the EditCommandForm component +jest.mock('./edit_command_form'); +jest.mocked(EditCommandForm).mockImplementation( + (props) => React.createElement('EditCommandForm', {testID: 'edit-command-form', ...props}), +); + +jest.mock('@hooks/navigation_button_pressed'); +jest.mock('@hooks/android_back_handler'); + +const serverUrl = 'some.server.url'; + +describe('EditCommand', () => { + let database: Database; + + beforeEach(async () => { + await DatabaseManager.init([serverUrl]); + database = DatabaseManager.getServerDatabaseAndOperator(serverUrl).database; + }); + + afterEach(async () => { + await DatabaseManager.destroyServerDatabase(serverUrl); + }); + + function getBaseProps(): ComponentProps { + return { + componentId: 'EditCommand' as const, + savedCommand: '/test command', + updateCommand: jest.fn(), + channelId: 'channel-123', + }; + } + + it('renders correctly with default props', () => { + const props = getBaseProps(); + const {getByTestId} = renderWithEverything(, {database}); + + const editCommandForm = getByTestId('edit-command-form'); + expect(editCommandForm).toBeTruthy(); + expect(editCommandForm.props.command).toBe(props.savedCommand); + expect(editCommandForm.props.onCommandChange).toBeDefined(); + expect(editCommandForm.props.channelId).toBe(props.channelId); + }); + + it('renders correctly without saved command', () => { + const props = getBaseProps(); + props.savedCommand = undefined; + const {getByTestId} = renderWithEverything(, {database}); + + const editCommandForm = getByTestId('edit-command-form'); + expect(editCommandForm.props.command).toBe(''); + }); + + it('sets up navigation buttons correctly', () => { + const props = getBaseProps(); + renderWithEverything(, {database}); + + expect(setButtons).toHaveBeenCalledWith(props.componentId, { + rightButtons: [expect.objectContaining({ + id: 'save-command', + enabled: false, + showAsAction: 'always', + text: 'Save', + })], + }); + }); + + it('enables save button when command changes', () => { + const props = getBaseProps(); + const {getByTestId} = renderWithEverything(, {database}); + + const editCommandForm = getByTestId('edit-command-form'); + + // Initially, save button should be disabled + expect(setButtons).toHaveBeenCalledWith(props.componentId, { + rightButtons: [expect.objectContaining({enabled: false})], + }); + + jest.mocked(setButtons).mockClear(); + + act(() => { + // Simulate command change + editCommandForm.props.onCommandChange('/new command'); + }); + + // Save button should now be enabled + expect(setButtons).toHaveBeenCalledWith(props.componentId, { + rightButtons: [expect.objectContaining({enabled: true})], + }); + + jest.mocked(setButtons).mockClear(); + + act(() => { + // Change back to original + editCommandForm.props.onCommandChange(props.savedCommand); + }); + + // Save button should now be enabled + expect(setButtons).toHaveBeenCalledWith(props.componentId, { + rightButtons: [expect.objectContaining({enabled: false})], + }); + }); + + it('calls updateCommand and closes when save button is pressed', () => { + const props = getBaseProps(); + const updateCommandMock = jest.fn(); + props.updateCommand = updateCommandMock; + + renderWithEverything(, {database}); + + // Get the onEditCommand function that was passed to useNavButtonPressed + const onEditCommand = jest.mocked(useNavButtonPressed).mock.calls[0][2]; + + // Simulate save button press + onEditCommand(); + + expect(updateCommandMock).toHaveBeenCalledWith(props.savedCommand); + expect(Keyboard.dismiss).toHaveBeenCalled(); + expect(popTopScreen).toHaveBeenCalledWith(props.componentId); + }); + + it('closes without updating when close is called', () => { + const props = getBaseProps(); + const updateCommandMock = jest.fn(); + props.updateCommand = updateCommandMock; + + renderWithEverything(, {database}); + + // Get the handleClose function that was passed to useAndroidHardwareBackHandler + const handleClose = jest.mocked(useAndroidHardwareBackHandler).mock.calls[0][1]; + + // Simulate close + handleClose(); + + expect(updateCommandMock).not.toHaveBeenCalled(); + expect(Keyboard.dismiss).toHaveBeenCalled(); + expect(popTopScreen).toHaveBeenCalledWith(props.componentId); + }); + + it('updates command state when onCommandChange is called', () => { + const props = getBaseProps(); + const {getByTestId} = renderWithEverything(, {database}); + + const editCommandForm = getByTestId('edit-command-form'); + + // Change command + const newCommand = '/new command'; + act(() => { + editCommandForm.props.onCommandChange(newCommand); + }); + + const updatedForm = getByTestId('edit-command-form'); + expect(updatedForm.props.command).toBe(newCommand); + }); +}); diff --git a/app/products/playbooks/screens/edit_command/edit_command.tsx b/app/products/playbooks/screens/edit_command/edit_command.tsx new file mode 100644 index 000000000..0cdb75e9c --- /dev/null +++ b/app/products/playbooks/screens/edit_command/edit_command.tsx @@ -0,0 +1,99 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import {useIntl} from 'react-intl'; +import {Keyboard, StyleSheet, View} from 'react-native'; + +import {useTheme} from '@context/theme'; +import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; +import useNavButtonPressed from '@hooks/navigation_button_pressed'; +import SecurityManager from '@managers/security_manager'; +import {buildNavigationButton, popTopScreen, setButtons} from '@screens/navigation'; + +import EditCommandForm from './edit_command_form'; + +import type {AvailableScreens} from '@typings/screens/navigation'; + +type Props = { + componentId: AvailableScreens; + savedCommand?: string; + updateCommand: (command: string) => void; + channelId: string; +} + +const SAVE_BUTTON_ID = 'save-command'; + +const close = (componentId: AvailableScreens): void => { + Keyboard.dismiss(); + popTopScreen(componentId); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, +}); + +const CreateOrEditChannel = ({ + componentId, + savedCommand, + updateCommand, + channelId, +}: Props) => { + const intl = useIntl(); + const {formatMessage} = intl; + const theme = useTheme(); + + const [canSave, setCanSave] = useState(false); + const [command, setCommand] = useState(savedCommand || ''); + + const rightButton = useMemo(() => { + const base = buildNavigationButton( + SAVE_BUTTON_ID, + 'playbooks.edit_command.save.button', + undefined, + formatMessage({id: 'playbooks.edit_command.save.button', defaultMessage: 'Save'}), + ); + base.enabled = canSave; + base.color = theme.sidebarHeaderTextColor; + return base; + }, [formatMessage, canSave, theme.sidebarHeaderTextColor]); + + useEffect(() => { + setButtons(componentId, { + rightButtons: [rightButton], + }); + }, [rightButton, componentId]); + + useEffect(() => { + setCanSave(command !== savedCommand); + }, [command, savedCommand]); + + const handleClose = useCallback(() => { + close(componentId); + }, [componentId]); + + const onEditCommand = useCallback(() => { + close(componentId); + updateCommand(command); + }, [command, componentId, updateCommand]); + + useNavButtonPressed(SAVE_BUTTON_ID, componentId, onEditCommand, [onEditCommand]); + useAndroidHardwareBackHandler(componentId, handleClose); + + return ( + + + + ); +}; + +export default CreateOrEditChannel; diff --git a/app/products/playbooks/screens/edit_command/edit_command_form.test.tsx b/app/products/playbooks/screens/edit_command/edit_command_form.test.tsx new file mode 100644 index 000000000..ffc90ebb3 --- /dev/null +++ b/app/products/playbooks/screens/edit_command/edit_command_form.test.tsx @@ -0,0 +1,126 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {type ComponentProps} from 'react'; + +import Autocomplete from '@components/autocomplete'; +import FloatingTextInput from '@components/floating_text_input_label'; +import DatabaseManager from '@database/manager'; +import {renderWithEverything} from '@test/intl-test-helper'; + +import EditCommandForm from './edit_command_form'; + +import type {Database} from '@nozbe/watermelondb'; + +jest.mock('@components/floating_text_input_label', () => ({ + __esModule: true, + default: jest.fn(), +})); +jest.mocked(FloatingTextInput).mockImplementation((props) => React.createElement('FloatingTextInput', {...props})); + +jest.mock('@components/autocomplete', () => ({ + __esModule: true, + default: jest.fn(), +})); +jest.mocked(Autocomplete).mockImplementation((props) => React.createElement('Autocomplete', {testID: 'autocomplete', ...props})); + +const serverUrl = 'some.server.url'; + +describe('EditCommandForm', () => { + let database: Database; + + beforeEach(async () => { + await DatabaseManager.init([serverUrl]); + database = DatabaseManager.getServerDatabaseAndOperator(serverUrl).database; + + // Reset all mocks + jest.clearAllMocks(); + }); + + afterEach(async () => { + await DatabaseManager.destroyServerDatabase(serverUrl); + }); + + function getBaseProps(): ComponentProps { + return { + command: '/test command', + onCommandChange: jest.fn(), + channelId: 'channel-123', + }; + } + + it('renders correctly with default props', () => { + const props = getBaseProps(); + const {getByTestId} = renderWithEverything(, {database}); + + const floatingTextInput = getByTestId('playbooks.edit_command.input'); + expect(floatingTextInput).toBeTruthy(); + expect(floatingTextInput).toHaveProp('value', props.command); + expect(floatingTextInput).toHaveProp('onChangeText', props.onCommandChange); + expect(floatingTextInput).toHaveProp('autoCorrect', false); + expect(floatingTextInput).toHaveProp('autoCapitalize', 'none'); + expect(floatingTextInput).toHaveProp('disableFullscreenUI', true); + expect(floatingTextInput).toHaveProp('showErrorIcon', false); + expect(floatingTextInput).toHaveProp('spellCheck', false); + expect(floatingTextInput).toHaveProp('disableFullscreenUI', true); + + const autocomplete = getByTestId('autocomplete'); + expect(autocomplete).toBeTruthy(); + expect(autocomplete).toHaveProp('value', props.command); + expect(autocomplete).toHaveProp('updateValue', props.onCommandChange); + expect(autocomplete).toHaveProp('cursorPosition', props.command.length); + expect(autocomplete).toHaveProp('nestedScrollEnabled', true); + expect(autocomplete).toHaveProp('shouldDirectlyReact', false); + expect(autocomplete).toHaveProp('channelId', props.channelId); + expect(autocomplete).toHaveProp('autocompleteProviders', { + user: false, + channel: false, + emoji: false, + slash: true, + }); + }); + + it('calls onCommandChange when text input changes', () => { + const props = getBaseProps(); + const onCommandChangeMock = jest.fn(); + props.onCommandChange = onCommandChangeMock; + + const {getByTestId} = renderWithEverything(, {database}); + + const floatingTextInput = getByTestId('playbooks.edit_command.input'); + const newCommand = '/new command'; + floatingTextInput.props.onChangeText(newCommand); + + expect(onCommandChangeMock).toHaveBeenCalledWith(newCommand); + }); + + it('calls onCommandChange when autocomplete updates value', () => { + const props = getBaseProps(); + const onCommandChangeMock = jest.fn(); + props.onCommandChange = onCommandChangeMock; + + const {getByTestId} = renderWithEverything(, {database}); + + const autocomplete = getByTestId('autocomplete'); + const newCommand = '/autocomplete command'; + autocomplete.props.updateValue(newCommand); + + expect(onCommandChangeMock).toHaveBeenCalledWith(newCommand); + }); + + it('updates cursor position when command changes', () => { + const props = getBaseProps(); + const {getByTestId, rerender} = renderWithEverything(, {database}); + + const autocomplete = getByTestId('autocomplete'); + expect(autocomplete.props.cursorPosition).toBe(props.command.length); + + // Update command + const newProps = {...props, command: '/updated command'}; + rerender(); + + const updatedAutocomplete = getByTestId('autocomplete'); + expect(updatedAutocomplete.props.cursorPosition).toBe(newProps.command.length); + }); +}); + diff --git a/app/products/playbooks/screens/edit_command/edit_command_form.tsx b/app/products/playbooks/screens/edit_command/edit_command_form.tsx new file mode 100644 index 000000000..d3f05f191 --- /dev/null +++ b/app/products/playbooks/screens/edit_command/edit_command_form.tsx @@ -0,0 +1,122 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useState, useCallback} from 'react'; +import {useIntl} from 'react-intl'; +import { + type LayoutChangeEvent, + View, + Platform, + StyleSheet, +} from 'react-native'; +import {SafeAreaView, type Edges} from 'react-native-safe-area-context'; + +import Autocomplete from '@components/autocomplete'; +import FloatingTextInput from '@components/floating_text_input_label'; +import {useTheme} from '@context/theme'; +import {useAutocompleteDefaultAnimatedValues} from '@hooks/autocomplete'; +import { + getKeyboardAppearanceFromTheme, +} from '@utils/theme'; + +const BOTTOM_AUTOCOMPLETE_SEPARATION = Platform.select({ios: 10, default: 10}); +const LIST_PADDING = 32; +const AUTOCOMPLETE_ADJUST = 5; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + mainView: { + paddingVertical: LIST_PADDING, + paddingHorizontal: 20, + }, +}); + +type Props = { + command: string; + onCommandChange: (text: string) => void; + channelId: string; +} + +const edges: Edges = ['bottom', 'left', 'right']; +const autocompleteProviders = { + user: false, + channel: false, + emoji: false, + slash: true, +}; + +export default function EditCommandForm({ + command, + onCommandChange, + channelId, +}: Props) { + const intl = useIntl(); + const {formatMessage} = intl; + const theme = useTheme(); + + const [wrapperHeight, setWrapperHeight] = useState(0); + + const [commandFieldHeight, setCommandFieldHeight] = useState(0); + + const labelCommand = formatMessage({id: 'playbooks.edit_command.label', defaultMessage: 'Command'}); + const placeholderCommand = formatMessage({id: 'playbooks.edit_command.placeholder', defaultMessage: 'Type a command here'}); + + const onLayoutCommand = useCallback((e: LayoutChangeEvent) => { + setCommandFieldHeight(e.nativeEvent.layout.height); + }, []); + const onLayoutWrapper = useCallback((e: LayoutChangeEvent) => { + setWrapperHeight(e.nativeEvent.layout.height); + }, []); + + const spaceOnTop = LIST_PADDING - AUTOCOMPLETE_ADJUST; + const spaceOnBottom = (wrapperHeight) - (LIST_PADDING + commandFieldHeight + BOTTOM_AUTOCOMPLETE_SEPARATION); + + const bottomPosition = (LIST_PADDING + commandFieldHeight); + const topPosition = (wrapperHeight + AUTOCOMPLETE_ADJUST) - LIST_PADDING; + const autocompletePosition = spaceOnBottom > spaceOnTop ? bottomPosition : topPosition; + const autocompleteAvailableSpace = spaceOnBottom > spaceOnTop ? spaceOnBottom : spaceOnTop; + const growDown = spaceOnBottom > spaceOnTop; + + const [animatedAutocompletePosition, animatedAutocompleteAvailableSpace] = useAutocompleteDefaultAnimatedValues(autocompletePosition, autocompleteAvailableSpace); + + return ( + + + + + + + ); +} diff --git a/app/products/playbooks/screens/edit_command/index.tsx b/app/products/playbooks/screens/edit_command/index.tsx new file mode 100644 index 000000000..a07b95b40 --- /dev/null +++ b/app/products/playbooks/screens/edit_command/index.tsx @@ -0,0 +1,4 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export {default} from './edit_command'; diff --git a/app/products/playbooks/screens/index.test.tsx b/app/products/playbooks/screens/index.test.tsx new file mode 100644 index 000000000..3d1c53190 --- /dev/null +++ b/app/products/playbooks/screens/index.test.tsx @@ -0,0 +1,66 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Text, View} from 'react-native'; + +import {withServerDatabase} from '@database/components'; +import Screens from '@playbooks/constants/screens'; +import {render} from '@test/intl-test-helper'; + +import EditCommand from './edit_command'; +import PlaybookRun from './playbook_run'; +import PlaybookRuns from './playbooks_runs'; + +import {loadPlaybooksScreen} from '.'; + +jest.mock('@database/components', () => ({ + withServerDatabase: jest.fn(), +})); +jest.mocked(withServerDatabase).mockImplementation((Component) => function MockedWithServerDatabase(props: any) { + return ; +}); + +jest.mock('@playbooks/screens/playbooks_runs', () => ({ + __esModule: true, + default: jest.fn(), +})); +jest.mocked(PlaybookRuns).mockImplementation((props) => {Screens.PLAYBOOKS_RUNS}); + +jest.mock('@playbooks/screens/playbook_run', () => ({ + __esModule: true, + default: jest.fn(), +})); +jest.mocked(PlaybookRun).mockImplementation((props) => {Screens.PLAYBOOK_RUN}); + +jest.mock('@playbooks/screens/edit_command', () => ({ + __esModule: true, + default: jest.fn(), +})); +jest.mocked(EditCommand).mockImplementation((props) => {Screens.PLAYBOOK_EDIT_COMMAND}); + +describe('Screen Registration', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it.each(Object.values(Screens))('register screen %s when requested', (screenName) => { + const Screen = loadPlaybooksScreen(screenName); + expect(Screen).toBeDefined(); + + // the previous expect will fail if the screen is not defined. + // This is a workaround to keep the types correct. + if (!Screen) { + return; + } + + const {getByTestId, getByText} = render(); + + expect(getByTestId('withDatabase')).toBeDefined(); + expect(getByText(screenName)).toBeDefined(); + }); + + it('returns undefined for unknown screen names', () => { + const Screen = loadPlaybooksScreen('unknown'); + expect(Screen).toBeUndefined(); + }); +}); diff --git a/app/products/playbooks/screens/index.tsx b/app/products/playbooks/screens/index.tsx new file mode 100644 index 000000000..5908f0162 --- /dev/null +++ b/app/products/playbooks/screens/index.tsx @@ -0,0 +1,18 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {withServerDatabase} from '@database/components'; +import Screens from '@playbooks/constants/screens'; + +export function loadPlaybooksScreen(screenName: string | number) { + switch (screenName) { + case Screens.PLAYBOOKS_RUNS: + return withServerDatabase(require('@playbooks/screens/playbooks_runs').default); + case Screens.PLAYBOOK_RUN: + return withServerDatabase(require('@playbooks/screens/playbook_run').default); + case Screens.PLAYBOOK_EDIT_COMMAND: + return withServerDatabase(require('@playbooks/screens/edit_command').default); + default: + return undefined; + } +} 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 af91ddeb9..f55ef2e1a 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 @@ -4,9 +4,10 @@ import {act, fireEvent, waitFor} from '@testing-library/react-native'; import React, {type ComponentProps} from 'react'; +import {handleCallsSlashCommand} from '@calls/actions'; import BaseChip from '@components/chips/base_chip'; import UserChip from '@components/chips/user_chip'; -import {Preferences} from '@constants'; +import {General, Preferences} from '@constants'; import {useServerUrl} from '@context/server'; import {runChecklistItem, skipChecklistItem, updateChecklistItem} from '@playbooks/actions/remote/checklist'; import {bottomSheet, openUserProfileModal, popTo} from '@screens/navigation'; @@ -34,6 +35,7 @@ jest.mocked(BaseChip).mockImplementation((props) => React.createElement('BaseChi jest.mock('./checklist_item_bottom_sheet'); jest.mocked(ChecklistItemBottomSheet).mockImplementation((props) => React.createElement('ChecklistItemBottomSheet', {...props, testID: 'checklist-item-bottom-sheet-component'})); +jest.mock('@calls/actions'); jest.mock('@playbooks/actions/remote/checklist'); jest.mock('@utils/snack_bar'); @@ -60,6 +62,8 @@ describe('ChecklistItem', () => { itemNumber: 0, playbookRunId: 'run-id-1', isDisabled: false, + currentUserId: 'user-id-1', + channelType: General.OPEN_CHANNEL, }; } @@ -303,7 +307,7 @@ describe('ChecklistItem', () => { expect(chip.props.prefix.props.style).toBeDefined(); let resolve: (value: {data: boolean}) => void; - jest.mocked(runChecklistItem).mockImplementation(() => new Promise((r) => { + jest.mocked(runChecklistItem).mockImplementationOnce(() => new Promise((r) => { resolve = r; })); @@ -325,6 +329,29 @@ describe('ChecklistItem', () => { await waitFor(() => { expect(getByTestId('base-chip-component')).toBeVisible(); expect(popTo).toHaveBeenCalledWith('Channel'); + expect(handleCallsSlashCommand).not.toHaveBeenCalled(); + }); + }); + + it('should call handleCallsSlashCommand when the command is a call command', async () => { + const props = getBaseProps(); + const item = TestHelper.fakePlaybookChecklistItemModel({}); + item.command = '/call start'; + props.item = item; + + const {getByTestId} = renderWithIntl(); + + const chip = getByTestId('base-chip-component'); + jest.mocked(runChecklistItem).mockResolvedValueOnce({data: true}); + jest.mocked(handleCallsSlashCommand).mockResolvedValueOnce({handled: true}); + + act(() => { + chip.props.onPress(); + }); + + await waitFor(() => { + expect(runChecklistItem).toHaveBeenCalled(); + expect(handleCallsSlashCommand).toHaveBeenCalledWith(item.command, serverUrl, props.channelId, props.channelType, '', props.currentUserId, expect.anything()); }); }); @@ -352,6 +379,8 @@ describe('ChecklistItem', () => { it('opens the bottom sheet when the item is pressed', async () => { const props = getBaseProps(); + props.itemNumber = 2; + props.checklistNumber = 4; const {getByText} = renderWithIntl(); @@ -371,8 +400,11 @@ describe('ChecklistItem', () => { 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); + expect(bottomSheetRenderedComponent.props.runId).toBe(props.playbookRunId); + expect(bottomSheetRenderedComponent.props.checklistNumber).toBe(props.checklistNumber); + expect(bottomSheetRenderedComponent.props.itemNumber).toBe(props.itemNumber); + expect(bottomSheetRenderedComponent.props.channelId).toBe(props.channelId); bottomSheetRenderedComponent.props.onCheck(); 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 20b6607a5..7b9391116 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 @@ -5,6 +5,7 @@ import React, {useCallback, useMemo, useState} from 'react'; import {useIntl} from 'react-intl'; import {View, Text, ActivityIndicator} from 'react-native'; +import {handleCallsSlashCommand} from '@calls/actions'; import BaseChip from '@components/chips/base_chip'; import UserChip from '@components/chips/user_chip'; import CompassIcon from '@components/compass_icon'; @@ -78,6 +79,8 @@ type Props = { itemNumber: number; playbookRunId: string; isDisabled: boolean; + currentUserId: string; + channelType: ChannelType; } const ChecklistItem = ({ @@ -89,6 +92,8 @@ const ChecklistItem = ({ itemNumber, playbookRunId, isDisabled, + currentUserId, + channelType, }: Props) => { const dueDate = 'dueDate' in item ? item.dueDate : item.due_date; const theme = useTheme(); @@ -120,11 +125,15 @@ const ChecklistItem = ({ const res = await runChecklistItem(serverUrl, playbookRunId, checklistNumber, itemNumber); if (res.error) { showPlaybookErrorSnackbar(); - } else { - popTo('Channel'); + setIsExecuting(false); + return; } - setIsExecuting(false); - }, [checklistNumber, isExecuting, itemNumber, playbookRunId, serverUrl]); + + popTo('Channel'); + if (item.command?.startsWith('/call')) { + await handleCallsSlashCommand(item.command, serverUrl, channelId, channelType, '', currentUserId, intl); + } + }, [channelId, channelType, checklistNumber, currentUserId, intl, isExecuting, item.command, itemNumber, playbookRunId, serverUrl]); const toggleChecked = useCallback(async () => { if (isChecking) { @@ -193,15 +202,29 @@ const ChecklistItem = ({ const renderBottomSheet = useCallback(() => ( - ), [assignee, executeCommand, item, teammateNameDisplay, toggleChecked, toggleSkipped, isDisabled]); + ), [ + playbookRunId, + checklistNumber, + itemNumber, + channelId, + item, + toggleChecked, + toggleSkipped, + executeCommand, + teammateNameDisplay, + isDisabled, + ]); const onPress = useCallback(() => { const initialHeight = BOTTOM_SHEET_HEIGHT.base + (isDisabled ? 0 : BOTTOM_SHEET_HEIGHT.actionButtons); diff --git a/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet.test.tsx b/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet/checklist_item_bottom_sheet.test.tsx similarity index 86% rename from app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet.test.tsx rename to app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet/checklist_item_bottom_sheet.test.tsx index 0f89b9e61..8be08e889 100644 --- a/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet.test.tsx +++ b/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet/checklist_item_bottom_sheet.test.tsx @@ -9,12 +9,15 @@ 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 {setChecklistItemCommand} from '@playbooks/actions/remote/checklist'; +import {dismissBottomSheet, goToScreen, openUserProfileModal} from '@screens/navigation'; import {renderWithIntl} from '@test/intl-test-helper'; import TestHelper from '@test/test_helper'; import ChecklistItemBottomSheet from './checklist_item_bottom_sheet'; +import type EditCommand from '@playbooks/screens/edit_command'; + jest.mock('@hooks/device', () => ({ useIsTablet: jest.fn(), })); @@ -30,6 +33,12 @@ jest.mocked(OptionBox).mockImplementation((props) => React.createElement('Option jest.mock('@components/option_item'); jest.mocked(OptionItem).mockImplementation((props) => React.createElement('OptionItem', props)); +jest.mock('@playbooks/actions/remote/checklist'); + +jest.mock('@context/server', () => ({ + useServerUrl: jest.fn().mockReturnValue('server-url'), +})); + describe('ChecklistItemBottomSheet', () => { const mockOnCheck = jest.fn(); const mockOnSkip = jest.fn(); @@ -65,6 +74,10 @@ describe('ChecklistItemBottomSheet', () => { function getBaseProps(): ComponentProps { return { + runId: 'run-1', + checklistNumber: 1, + itemNumber: 1, + channelId: 'channel-1', item: mockItem, assignee: mockAssignee, onCheck: mockOnCheck, @@ -287,6 +300,27 @@ describe('ChecklistItemBottomSheet', () => { expect(dueDateItem.props.info).toBe('None'); }); + it('command is disabled when isDisabled is true', () => { + const props = getBaseProps(); + props.isDisabled = true; + props.item = TestHelper.fakePlaybookChecklistItemModel({ + ...props.item, + command: 'test command', + }); + const {getByTestId, rerender} = renderWithIntl(); + + let commandItem = getByTestId('checklist_item.command'); + expect(commandItem).toHaveProp('type', 'none'); + expect(commandItem).toHaveProp('action', undefined); + + props.isDisabled = false; + rerender(); + + commandItem = getByTestId('checklist_item.command'); + expect(commandItem).toHaveProp('type', 'arrow'); + expect(commandItem).toHaveProp('action', expect.any(Function)); + }); + it('displays correct command information', () => { const props = getBaseProps(); props.item = TestHelper.fakePlaybookChecklistItemModel({ @@ -311,6 +345,42 @@ describe('ChecklistItemBottomSheet', () => { expect(commandItem.props.info).toBe('None'); }); + it('opens the command modal when the command is clicked', async () => { + const props = getBaseProps(); + props.checklistNumber = 2; + props.itemNumber = 4; + const {getByTestId} = renderWithIntl(); + const commandItem = getByTestId('checklist_item.command'); + + act(() => { + commandItem.props.action(); + }); + + expect(goToScreen).toHaveBeenCalledWith( + 'PlaybookEditCommand', + 'Slash command', + { + savedCommand: 'test command', + updateCommand: expect.any(Function), + channelId: 'channel-1', + }, + ); + + const updateCommand = (jest.mocked(goToScreen).mock.calls[0][2] as ComponentProps).updateCommand; + await act(async () => { + updateCommand('new command'); + }); + + expect(setChecklistItemCommand).toHaveBeenCalledWith( + 'server-url', + 'run-1', + 'item-1', + 2, + 4, + 'new command', + ); + }); + it('handles user profile modal opening correctly', async () => { const props = getBaseProps(); const {getByTestId} = renderWithIntl(); 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/checklist_item_bottom_sheet.tsx similarity index 87% rename from app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet.tsx rename to app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet/checklist_item_bottom_sheet.tsx index d74dd6f09..73c6104ef 100644 --- 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/checklist_item_bottom_sheet.tsx @@ -8,13 +8,15 @@ 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 {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; -import {dismissBottomSheet, openUserProfileModal} from '@screens/navigation'; +import {setChecklistItemCommand} from '@playbooks/actions/remote/checklist'; +import {dismissBottomSheet, goToScreen, 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 Checkbox from '../checkbox'; import type PlaybookChecklistItemModel from '@playbooks/types/database/models/playbook_checklist_item'; import type UserModel from '@typings/database/models/servers/user'; @@ -112,6 +114,10 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => ({ })); type Props = { + runId: string; + checklistNumber: number; + itemNumber: number; + channelId: string; item: PlaybookChecklistItemModel | PlaybookChecklistItem; assignee?: UserModel; onCheck: () => void; @@ -135,6 +141,10 @@ function getDueDateInfo(intl: IntlShape, dueDate: number | undefined) { } const ChecklistItemBottomSheet = ({ + runId, + checklistNumber, + itemNumber, + channelId, item, assignee, onCheck, @@ -146,6 +156,7 @@ const ChecklistItemBottomSheet = ({ const theme = useTheme(); const styles = getStyleSheet(theme); const intl = useIntl(); + const serverUrl = useServerUrl(); const dueDate = 'dueDate' in item ? item.dueDate : item.due_date; const isChecked = item.state === 'closed'; @@ -205,6 +216,22 @@ const ChecklistItemBottomSheet = ({ }); }, [intl, theme]); + const updateCommand = useCallback(async (command: string) => { + await setChecklistItemCommand(serverUrl, runId, item.id, checklistNumber, itemNumber, command); + }, [checklistNumber, item.id, itemNumber, runId, serverUrl]); + + const openEditCommandModal = useCallback(() => { + goToScreen( + 'PlaybookEditCommand', + intl.formatMessage({id: 'playbooks.edit_command.title', defaultMessage: 'Slash command'}), + { + savedCommand: item.command, + updateCommand, + channelId, + }, + ); + }, [intl, item.command, updateCommand, channelId]); + const assigneeInfo: ComponentProps['info'] = useMemo(() => { if (!assignee) { return intl.formatMessage(messages.none); @@ -234,12 +261,13 @@ const ChecklistItemBottomSheet = ({ testID='checklist_item.due_date' /> ); diff --git a/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet/index.test.tsx b/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet/index.test.tsx new file mode 100644 index 000000000..b69d01db9 --- /dev/null +++ b/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet/index.test.tsx @@ -0,0 +1,213 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {type ComponentProps} from 'react'; + +import {General} from '@constants'; +import DatabaseManager from '@database/manager'; +import {getPlaybookChecklistItemById} from '@playbooks/database/queries/item'; +import {act, renderWithEverything, waitFor} from '@test/intl-test-helper'; +import TestHelper from '@test/test_helper'; + +import ChecklistItemBottomSheetComponent from './checklist_item_bottom_sheet'; + +import ChecklistItemBottomSheet from './index'; + +import type ServerDataOperator from '@database/operator/server_data_operator'; +import type {Database} from '@nozbe/watermelondb'; + +jest.mock('./checklist_item_bottom_sheet', () => ({ + __esModule: true, + default: jest.fn(), +})); +jest.mocked(ChecklistItemBottomSheetComponent).mockImplementation( + (props) => React.createElement('ChecklistItemBottomSheet', {testID: 'checklist_item_bottom_sheet', ...props}), +); + +describe('ChecklistItemBottomSheet Enhanced Component', () => { + const serverUrl = 'server-url'; + + let database: Database; + let operator: ServerDataOperator; + + function getBaseProps(): ComponentProps { + return { + item: TestHelper.fakePlaybookChecklistItem('checklist-id', {id: 'item-1'}), + runId: 'run-1', + checklistNumber: 1, + itemNumber: 1, + channelId: 'channel-1', + onCheck: jest.fn(), + onSkip: jest.fn(), + onRunCommand: jest.fn(), + teammateNameDisplay: General.TEAMMATE_NAME_DISPLAY.SHOW_USERNAME, + isDisabled: false, + }; + } + + async function addItemToDatabase(item: PartialChecklistItem) { + await operator.handlePlaybookChecklistItem({ + prepareRecordsOnly: false, + items: [item], + }); + } + + async function addUserToDatabase(user: UserProfile) { + await operator.handleUsers({ + prepareRecordsOnly: false, + users: [user], + }); + } + + beforeEach(async () => { + await DatabaseManager.init([serverUrl]); + const serverDatabaseAndOperator = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + database = serverDatabaseAndOperator.database; + operator = serverDatabaseAndOperator.operator; + jest.clearAllMocks(); + }); + + afterEach(() => { + DatabaseManager.destroyServerDatabase(serverUrl); + }); + + describe('with database model item', () => { + it('should render enhanced component with database model item', async () => { + const rawItem = TestHelper.fakePlaybookChecklistItem('checklist-id', {id: 'item-1', assignee_id: 'user-1'}); + await addItemToDatabase(rawItem); + await addUserToDatabase(TestHelper.fakeUser({id: 'user-1'})); + + const item = await getPlaybookChecklistItemById(database, rawItem.id); + + const props = getBaseProps(); + props.item = item!; + + const {getByTestId} = renderWithEverything(, {database}); + + const bottomSheet = getByTestId('checklist_item_bottom_sheet'); + expect(bottomSheet).toBeTruthy(); + expect(bottomSheet).toHaveProp('item', item); + expect(bottomSheet).toHaveProp('assignee', expect.objectContaining({id: 'user-1'})); + }); + + it('should handle assigneeId changes through observable', async () => { + const rawItem = TestHelper.fakePlaybookChecklistItem('checklist-id', {id: 'item-1', assignee_id: 'user-1'}); + await addItemToDatabase(rawItem); + await addUserToDatabase(TestHelper.fakeUser({id: 'user-1'})); + await addUserToDatabase(TestHelper.fakeUser({id: 'user-2'})); + + const item = await getPlaybookChecklistItemById(database, rawItem.id); + + const props = getBaseProps(); + props.item = item!; + + const {getByTestId} = renderWithEverything(, {database}); + + const bottomSheet = getByTestId('checklist_item_bottom_sheet'); + expect(bottomSheet).toHaveProp('assignee', expect.objectContaining({id: 'user-1'})); + await act(async () => { + database.write(async () => { + item?.update((i) => { + i.assigneeId = 'user-2'; + }); + }); + }); + + await waitFor(() => { + expect(bottomSheet).toHaveProp('assignee', expect.objectContaining({id: 'user-2'})); + }); + }); + + it('should handle no assignee correctly', async () => { + const rawItem = TestHelper.fakePlaybookChecklistItem('checklist-id', {id: 'item-1', assignee_id: ''}); + await addItemToDatabase(rawItem); + + const props = getBaseProps(); + props.item = rawItem; + + const {getByTestId} = renderWithEverything(, {database}); + + const bottomSheet = getByTestId('checklist_item_bottom_sheet'); + expect(bottomSheet.props.assignee).toBeUndefined(); + }); + + it('should handle missing assignee correctly', async () => { + const rawItem = TestHelper.fakePlaybookChecklistItem('checklist-id', {id: 'item-1', assignee_id: 'missing-user-id'}); + await addItemToDatabase(rawItem); + + const props = getBaseProps(); + props.item = rawItem; + + const {getByTestId} = renderWithEverything(, {database}); + + const bottomSheet = getByTestId('checklist_item_bottom_sheet'); + expect(bottomSheet.props.assignee).toBeUndefined(); + }); + }); + + describe('with API item', () => { + it('should render enhanced component with API item', async () => { + const apiItem = TestHelper.fakePlaybookChecklistItem('checklist-id', {id: 'item-2', assignee_id: 'user-1'}); + await addUserToDatabase(TestHelper.fakeUser({id: 'user-1'})); + + const props = getBaseProps(); + props.item = apiItem; + + const {getByTestId} = renderWithEverything(, {database}); + + const bottomSheet = getByTestId('checklist_item_bottom_sheet'); + expect(bottomSheet).toBeTruthy(); + expect(bottomSheet).toHaveProp('item', apiItem); + expect(bottomSheet).toHaveProp('assignee', expect.objectContaining({id: 'user-1'})); + }); + + it('should handle assigneeId changes through observable', async () => { + const apiItem = TestHelper.fakePlaybookChecklistItem('checklist-id', {id: 'item-2', assignee_id: 'user-1'}); + await addUserToDatabase(TestHelper.fakeUser({id: 'user-1'})); + await addUserToDatabase(TestHelper.fakeUser({id: 'user-2'})); + + const props = getBaseProps(); + props.item = apiItem!; + + const {getByTestId, rerender} = renderWithEverything(, {database}); + + const bottomSheet = getByTestId('checklist_item_bottom_sheet'); + expect(bottomSheet).toHaveProp('assignee', expect.objectContaining({id: 'user-1'})); + + // Clone the objet to pass a new reference to the render + props.item = {...props.item}; + props.item.assignee_id = 'user-2'; + rerender(); + + expect(bottomSheet).toHaveProp('assignee', expect.objectContaining({id: 'user-2'})); + }); + + it('should handle no assignee correctly', () => { + const apiItem = TestHelper.fakePlaybookChecklistItem('checklist-id', {id: 'item-2', assignee_id: ''}); + + const props = getBaseProps(); + props.item = apiItem; + + const {getByTestId} = renderWithEverything(, {database}); + + const bottomSheet = getByTestId('checklist_item_bottom_sheet'); + expect(bottomSheet).toBeTruthy(); + expect(bottomSheet).toHaveProp('item', apiItem); + expect(bottomSheet.props.assignee).toBeUndefined(); + }); + + it('should handle missing assignee correctly', () => { + const apiItem = TestHelper.fakePlaybookChecklistItem('checklist-id', {id: 'item-2', assignee_id: 'missing-user-id'}); + + const props = getBaseProps(); + props.item = apiItem; + + const {getByTestId} = renderWithEverything(, {database}); + + const bottomSheet = getByTestId('checklist_item_bottom_sheet'); + expect(bottomSheet).toBeTruthy(); + expect(bottomSheet).toHaveProp('item', apiItem); + expect(bottomSheet.props.assignee).toBeUndefined(); + }); + }); +}); diff --git a/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet/index.ts b/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet/index.ts new file mode 100644 index 000000000..10e0bd544 --- /dev/null +++ b/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet/index.ts @@ -0,0 +1,48 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; +import {of as of$, switchMap} from 'rxjs'; + +import {observeUser} from '@queries/servers/user'; + +import ChecklistItemBottomSheet, {BOTTOM_SHEET_HEIGHT} from './checklist_item_bottom_sheet'; + +import type PlaybookChecklistItemModel from '@playbooks/types/database/models/playbook_checklist_item'; +import type {WithDatabaseArgs} from '@typings/database/database'; + +type OwnProps = { + item: PlaybookChecklistItemModel | PlaybookChecklistItem; +} & WithDatabaseArgs; + +const enhanced = withObservables(['item'], ({item, database}: OwnProps) => { + if ('observe' in item) { + const observedItem = item.observe(); + + // We don't use assignee query from the model because if it cannot find the user + // it will throw an error. + const assignee = observedItem.pipe( + switchMap((i) => { + if (i.assigneeId) { + return observeUser(database, i.assigneeId); + } + + return of$(undefined); + }), + ); + return { + item: observedItem, + assignee, + }; + } + + const assignee = observeUser(database, item.assignee_id); + + return { + item: of$(item), + assignee, + }; +}); + +export default withDatabase(enhanced(ChecklistItemBottomSheet)); +export {BOTTOM_SHEET_HEIGHT}; diff --git a/app/products/playbooks/screens/playbook_run/checklist/checklist_item/index.test.tsx b/app/products/playbooks/screens/playbook_run/checklist/checklist_item/index.test.tsx index 26acafe10..afd54b7b8 100644 --- a/app/products/playbooks/screens/playbook_run/checklist/checklist_item/index.test.tsx +++ b/app/products/playbooks/screens/playbook_run/checklist/checklist_item/index.test.tsx @@ -4,6 +4,7 @@ import React, {act, type ComponentProps} from 'react'; import {General, Preferences} from '@constants'; +import {SYSTEM_IDENTIFIERS} from '@constants/database'; import DatabaseManager from '@database/manager'; import {renderWithEverything, waitFor} from '@test/intl-test-helper'; import TestHelper from '@test/test_helper'; @@ -73,6 +74,63 @@ describe('ChecklistItem', () => { expect(checklistItem.props.teammateNameDisplay).toBe(General.TEAMMATE_NAME_DISPLAY.SHOW_FULLNAME); }); }); + + it('should set the correct channel type', async () => { + const props = { + item: TestHelper.fakePlaybookChecklistItem(checklistId, {id: itemId}), + channelId: 'channel-id', + checklistNumber: 0, + itemNumber: 0, + playbookRunId: 'run-id', + isDisabled: false, + }; + + const {getByTestId} = renderWithEverything(, {database}); + + // Default value is open channel + const checklistItem = getByTestId('checklist-item'); + expect(checklistItem.props.channelType).toBe(General.OPEN_CHANNEL); + + await act((async () => { + await operator.handleChannel({ + prepareRecordsOnly: false, + channels: [TestHelper.fakeChannel({id: 'channel-id', type: General.PRIVATE_CHANNEL})], + }); + })); + + await waitFor(() => { + expect(checklistItem.props.channelType).toBe(General.PRIVATE_CHANNEL); + }); + }); + + it('should set the correct current user id', async () => { + const props = { + item: TestHelper.fakePlaybookChecklistItem(checklistId, {id: itemId}), + channelId: 'channel-id', + checklistNumber: 0, + itemNumber: 0, + playbookRunId: 'run-id', + isDisabled: false, + }; + + const {getByTestId} = renderWithEverything(, {database}); + + const checklistItem = getByTestId('checklist-item'); + + // Default value is empty string + expect(checklistItem.props.currentUserId).toBe(''); + + await act((async () => { + await operator.handleSystem({ + prepareRecordsOnly: false, + systems: [{id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID, value: 'user-id'}], + }); + })); + + await waitFor(() => { + expect(checklistItem.props.currentUserId).toBe('user-id'); + }); + }); }); describe('api run', () => { diff --git a/app/products/playbooks/screens/playbook_run/checklist/checklist_item/index.ts b/app/products/playbooks/screens/playbook_run/checklist/checklist_item/index.ts index 97705b0b0..765b4a9f8 100644 --- a/app/products/playbooks/screens/playbook_run/checklist/checklist_item/index.ts +++ b/app/products/playbooks/screens/playbook_run/checklist/checklist_item/index.ts @@ -4,6 +4,9 @@ import {withDatabase, withObservables} from '@nozbe/watermelondb/react'; import {of as of$, switchMap} from 'rxjs'; +import {General} from '@constants'; +import {observeChannel} from '@queries/servers/channel'; +import {observeCurrentUserId} from '@queries/servers/system'; import {observeTeammateNameDisplay, observeUser} from '@queries/servers/user'; import ChecklistItem from './checklist_item'; @@ -13,10 +16,13 @@ import type {WithDatabaseArgs} from '@typings/database/database'; type OwnProps = { item: PlaybookChecklistItemModel | PlaybookChecklistItem; + channelId: string; } & WithDatabaseArgs; -const enhanced = withObservables(['item'], ({item, database}: OwnProps) => { +const enhanced = withObservables(['item', 'channelId'], ({item, database, channelId}: OwnProps) => { const teammateNameDisplay = observeTeammateNameDisplay(database); + const currentUserId = observeCurrentUserId(database); + const channelType = observeChannel(database, channelId).pipe(switchMap((c) => of$(c?.type || General.OPEN_CHANNEL))); if ('observe' in item) { const observedItem = item.observe(); @@ -36,6 +42,8 @@ const enhanced = withObservables(['item'], ({item, database}: OwnProps) => { item: observedItem, assignee, teammateNameDisplay, + currentUserId, + channelType, }; } @@ -45,6 +53,8 @@ const enhanced = withObservables(['item'], ({item, database}: OwnProps) => { item: of$(item), assignee, teammateNameDisplay, + currentUserId, + channelType, }; }); diff --git a/app/screens/index.test.tsx b/app/screens/index.test.tsx index dc6e084c4..b175c8a0f 100644 --- a/app/screens/index.test.tsx +++ b/app/screens/index.test.tsx @@ -11,8 +11,10 @@ import {SafeAreaProvider} from 'react-native-safe-area-context'; import {Screens} from '@constants'; import {withServerDatabase} from '@database/components'; +import EditCommand from '@playbooks/screens/edit_command'; import PlaybookRun from '@playbooks/screens/playbook_run'; import PlaybooksRuns from '@playbooks/screens/playbooks_runs'; +import {logDebug} from '@utils/log'; import EditServer from './edit_server'; import InAppNotification from './in_app_notification'; @@ -106,6 +108,12 @@ jest.mock('@playbooks/screens/playbook_run', () => ({ })); jest.mocked(PlaybookRun).mockImplementation((props) => {Screens.PLAYBOOK_RUN}); +jest.mock('@playbooks/screens/edit_command', () => ({ + __esModule: true, + default: jest.fn(), +})); +jest.mocked(EditCommand).mockImplementation((props) => {Screens.PLAYBOOK_EDIT_COMMAND}); + describe('Screen Registration', () => { let registrator: (screenName: string) => void; @@ -192,6 +200,17 @@ describe('Screen Registration', () => { platform: undefined, }, ], + [ + Screens.PLAYBOOK_EDIT_COMMAND, + { + withServerDatabase: false, + withGestures: true, + withSafeAreaInsets: true, + withManagedConfig: true, + withIntl: true, + platform: undefined, + }, + ], ]; it.each(ttcc)('register screen %s when requested', (screenName, testCase) => { @@ -228,5 +247,6 @@ describe('Screen Registration', () => { it('handles unknown screen names gracefully', () => { registrator('UNKNOWN_SCREEN'); expect(Navigation.registerComponent).not.toHaveBeenCalled(); + expect(logDebug).toHaveBeenCalledWith('Screen not found: UNKNOWN_SCREEN'); }); }); diff --git a/app/screens/index.tsx b/app/screens/index.tsx index bd9ac6944..43268e958 100644 --- a/app/screens/index.tsx +++ b/app/screens/index.tsx @@ -12,6 +12,8 @@ import {SafeAreaProvider} from 'react-native-safe-area-context'; import {Screens} from '@constants'; import {withServerDatabase} from '@database/components'; import {DEFAULT_LOCALE, getTranslations} from '@i18n'; +import {loadPlaybooksScreen} from '@playbooks/screens'; +import {logDebug} from '@utils/log'; const withGestures = (Screen: React.ComponentType) => { return function gestureHoc(props: any) { @@ -189,12 +191,6 @@ Navigation.setLazyComponentRegistrator((screenName) => { case Screens.PINNED_MESSAGES: screen = withServerDatabase(require('@screens/pinned_messages').default); break; - case Screens.PLAYBOOKS_RUNS: - screen = withServerDatabase(require('@playbooks/screens/playbooks_runs').default); - break; - case Screens.PLAYBOOK_RUN: - screen = withServerDatabase(require('@playbooks/screens/playbook_run').default); - break; case Screens.POST_OPTIONS: screen = withServerDatabase(require('@screens/post_options').default); break; @@ -308,6 +304,14 @@ Navigation.setLazyComponentRegistrator((screenName) => { break; } + if (!screen) { + screen = loadPlaybooksScreen(screenName); + } + + if (!screen) { + logDebug(`Screen not found: ${screenName}`); + } + if (screen) { Navigation.registerComponent(screenName, () => withGestures(withSafeAreaInsets(withManagedConfig(screen)))); } diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index df1c23476..de5360b25 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -998,6 +998,10 @@ "playbooks.checklist_item.run_command": "Run command", "playbooks.checklist_item.skip": "Skip", "playbooks.checklist_item.skipped": "Skipped", + "playbooks.edit_command.label": "Command", + "playbooks.edit_command.placeholder": "Type a command here", + "playbooks.edit_command.save.button": "Save", + "playbooks.edit_command.title": "Slash command", "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",