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
This commit is contained in:
Daniel Espino García 2025-09-08 14:29:15 +02:00 committed by GitHub
parent 881342cb5f
commit b227837fa5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 1539 additions and 62 deletions

View file

@ -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<typeof Autocomplete> {
return {
availableSpace: {value: 0} as SharedValue<number>,
cursorPosition: 0,
isAppsEnabled: true,
position: {value: 0} as SharedValue<number>,
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(<Autocomplete {...props}/>);
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(<Autocomplete {...props}/>);
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(<Autocomplete {...props}/>);
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(<Autocomplete {...props}/>);
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(<Autocomplete {...props}/>);
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();
});
});

View file

@ -64,8 +64,23 @@ type Props = {
growDown?: boolean;
teamId?: string;
containerStyle?: StyleProp<ViewStyle>;
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 && (
<AppSlashSuggestion
listStyle={style.listStyle}
updateValue={updateValue}
@ -145,29 +161,33 @@ const Autocomplete = ({
/>
)}
{(!appsTakeOver || !isAppsEnabled) && (<>
<AtMention
cursorPosition={cursorPosition}
listStyle={style.listStyle}
updateValue={updateValue}
onShowingChange={setShowingAtMention}
value={value || ''}
nestedScrollEnabled={nestedScrollEnabled}
isSearch={isSearch}
channelId={channelId}
teamId={teamId}
/>
<ChannelMention
cursorPosition={cursorPosition}
listStyle={style.listStyle}
updateValue={updateValue}
onShowingChange={setShowingChannelMention}
value={value || ''}
nestedScrollEnabled={nestedScrollEnabled}
isSearch={isSearch}
channelId={channelId}
teamId={teamId}
/>
{!isSearch &&
{autocompleteProviders.user && (
<AtMention
cursorPosition={cursorPosition}
listStyle={style.listStyle}
updateValue={updateValue}
onShowingChange={setShowingAtMention}
value={value || ''}
nestedScrollEnabled={nestedScrollEnabled}
isSearch={isSearch}
channelId={channelId}
teamId={teamId}
/>
)}
{autocompleteProviders.channel && (
<ChannelMention
cursorPosition={cursorPosition}
listStyle={style.listStyle}
updateValue={updateValue}
onShowingChange={setShowingChannelMention}
value={value || ''}
nestedScrollEnabled={nestedScrollEnabled}
isSearch={isSearch}
channelId={channelId}
teamId={teamId}
/>
)}
{!isSearch && autocompleteProviders.emoji && (
<EmojiSuggestion
cursorPosition={cursorPosition}
listStyle={style.listStyle}
@ -178,8 +198,8 @@ const Autocomplete = ({
rootId={rootId}
shouldDirectlyReact={shouldDirectlyReact}
/>
}
{showCommands && channelId &&
)}
{showCommands && channelId && autocompleteProviders.slash && (
<SlashSuggestion
listStyle={style.listStyle}
updateValue={updateValue}
@ -190,7 +210,7 @@ const Autocomplete = ({
rootId={rootId}
isAppsEnabled={isAppsEnabled}
/>
}
)}
{/* {(isSearch && enableDateSuggestion) &&
<DateSuggestion
cursorPosition={cursorPosition}

View file

@ -1,6 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import PLAYBOOKS_SCREENS from '@playbooks/constants/screens';
export const ABOUT = 'About';
export const ACCOUNT = 'Account';
export const APPS_FORM = 'AppForm';
@ -52,8 +54,6 @@ export const ONBOARDING = 'Onboarding';
export const PDF_VIEWER = 'PdfViewer';
export const PERMALINK = 'Permalink';
export const PINNED_MESSAGES = 'PinnedMessages';
export const PLAYBOOKS_RUNS = 'PlaybookRuns';
export const PLAYBOOK_RUN = 'PlaybookRun';
export const POST_OPTIONS = 'PostOptions';
export const POST_PRIORITY_PICKER = 'PostPriorityPicker';
export const REACTIONS = 'Reactions';
@ -141,8 +141,6 @@ export default {
PDF_VIEWER,
PERMALINK,
PINNED_MESSAGES,
PLAYBOOKS_RUNS,
PLAYBOOK_RUN,
POST_OPTIONS,
POST_PRIORITY_PICKER,
REACTIONS,
@ -178,6 +176,7 @@ export default {
THREAD_FOLLOW_BUTTON,
THREAD_OPTIONS,
USER_PROFILE,
...PLAYBOOKS_SCREENS,
} as const;
export const MODAL_SCREENS_WITHOUT_BACK = new Set<string>([

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -22,7 +22,8 @@ export interface ClientPlaybooksMix {
// skipChecklist: (playbookRunID: string, checklistNum: number) => Promise<void>;
// Slash Commands
runChecklistItemSlashCommand: (playbookRunId: string, checklistNumber: number, itemNumber: number) => Promise<void>;
runChecklistItemSlashCommand: (playbookRunId: string, checklistNumber: number, itemNumber: number) => Promise<{trigger_id: string}>;
setChecklistItemCommand: (playbookRunID: string, checklistNum: number, itemNum: number, command: string) => Promise<void>;
}
const ClientPlaybooks = <TBase extends Constructor<ClientBase>>(superclass: TBase) => class extends superclass {
@ -108,10 +109,21 @@ const ClientPlaybooks = <TBase extends Constructor<ClientBase>>(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;
};
};

View file

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

View file

@ -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<typeof EditCommand> {
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(<EditCommand {...props}/>, {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(<EditCommand {...props}/>, {database});
const editCommandForm = getByTestId('edit-command-form');
expect(editCommandForm.props.command).toBe('');
});
it('sets up navigation buttons correctly', () => {
const props = getBaseProps();
renderWithEverything(<EditCommand {...props}/>, {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(<EditCommand {...props}/>, {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(<EditCommand {...props}/>, {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(<EditCommand {...props}/>, {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(<EditCommand {...props}/>, {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);
});
});

View file

@ -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<string>(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 (
<View
nativeID={SecurityManager.getShieldScreenId(componentId)}
style={styles.container}
>
<EditCommandForm
command={command}
onCommandChange={setCommand}
channelId={channelId}
/>
</View>
);
};
export default CreateOrEditChannel;

View file

@ -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<typeof EditCommandForm> {
return {
command: '/test command',
onCommandChange: jest.fn(),
channelId: 'channel-123',
};
}
it('renders correctly with default props', () => {
const props = getBaseProps();
const {getByTestId} = renderWithEverything(<EditCommandForm {...props}/>, {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(<EditCommandForm {...props}/>, {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(<EditCommandForm {...props}/>, {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(<EditCommandForm {...props}/>, {database});
const autocomplete = getByTestId('autocomplete');
expect(autocomplete.props.cursorPosition).toBe(props.command.length);
// Update command
const newProps = {...props, command: '/updated command'};
rerender(<EditCommandForm {...newProps}/>);
const updatedAutocomplete = getByTestId('autocomplete');
expect(updatedAutocomplete.props.cursorPosition).toBe(newProps.command.length);
});
});

View file

@ -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 (
<SafeAreaView
edges={edges}
style={styles.container}
testID='playbooks.edit_command.form'
onLayout={onLayoutWrapper}
>
<View style={styles.mainView}>
<FloatingTextInput
autoCorrect={false}
autoCapitalize={'none'}
disableFullscreenUI={true}
label={labelCommand}
placeholder={placeholderCommand}
onChangeText={onCommandChange}
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
showErrorIcon={false}
spellCheck={false}
testID='playbooks.edit_command.input'
value={command}
theme={theme}
onLayout={onLayoutCommand}
/>
</View>
<Autocomplete
position={animatedAutocompletePosition}
updateValue={onCommandChange}
cursorPosition={command.length}
value={command}
nestedScrollEnabled={true}
availableSpace={animatedAutocompleteAvailableSpace}
shouldDirectlyReact={false}
growDown={growDown}
channelId={channelId}
autocompleteProviders={autocompleteProviders}
/>
</SafeAreaView>
);
}

View file

@ -0,0 +1,4 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export {default} from './edit_command';

View file

@ -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 <View testID='withDatabase'><Component {...props}/></View>;
});
jest.mock('@playbooks/screens/playbooks_runs', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mocked(PlaybookRuns).mockImplementation((props) => <Text {...props}>{Screens.PLAYBOOKS_RUNS}</Text>);
jest.mock('@playbooks/screens/playbook_run', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mocked(PlaybookRun).mockImplementation((props) => <Text {...props}>{Screens.PLAYBOOK_RUN}</Text>);
jest.mock('@playbooks/screens/edit_command', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mocked(EditCommand).mockImplementation((props) => <Text {...props}>{Screens.PLAYBOOK_EDIT_COMMAND}</Text>);
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(<Screen/>);
expect(getByTestId('withDatabase')).toBeDefined();
expect(getByText(screenName)).toBeDefined();
});
it('returns undefined for unknown screen names', () => {
const Screen = loadPlaybooksScreen('unknown');
expect(Screen).toBeUndefined();
});
});

View file

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

View file

@ -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(<ChecklistItem {...props}/>);
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(<ChecklistItem {...props}/>);
@ -371,8 +400,11 @@ describe('ChecklistItem', () => {
const {getByTestId} = renderWithIntl(<BottomSheetComponent/>);
const bottomSheetRenderedComponent = getByTestId('checklist-item-bottom-sheet-component');
expect(bottomSheetRenderedComponent.props.item).toBe(props.item);
expect(bottomSheetRenderedComponent.props.assignee).toBe(props.assignee);
expect(bottomSheetRenderedComponent.props.teammateNameDisplay).toBe(props.teammateNameDisplay);
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();

View file

@ -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(() => (
<ChecklistItemBottomSheet
runId={playbookRunId}
checklistNumber={checklistNumber}
itemNumber={itemNumber}
channelId={channelId}
item={item}
assignee={assignee}
onCheck={toggleChecked}
onSkip={toggleSkipped}
onRunCommand={executeCommand}
teammateNameDisplay={teammateNameDisplay}
isDisabled={isDisabled}
/>
), [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);

View file

@ -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<typeof ChecklistItemBottomSheet> {
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(<ChecklistItemBottomSheet {...props}/>);
let commandItem = getByTestId('checklist_item.command');
expect(commandItem).toHaveProp('type', 'none');
expect(commandItem).toHaveProp('action', undefined);
props.isDisabled = false;
rerender(<ChecklistItemBottomSheet {...props}/>);
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(<ChecklistItemBottomSheet {...props}/>);
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<typeof EditCommand>).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(<ChecklistItemBottomSheet {...props}/>);

View file

@ -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<typeof OptionItem>['info'] = useMemo(() => {
if (!assignee) {
return intl.formatMessage(messages.none);
@ -234,12 +261,13 @@ const ChecklistItemBottomSheet = ({
testID='checklist_item.due_date'
/>
<OptionItem
type='none'
type={isDisabled ? 'none' : 'arrow'}
icon='slash-forward'
label={intl.formatMessage(messages.command)}
info={item.command || intl.formatMessage(messages.none)}
testID='checklist_item.command'
longInfo={true}
action={isDisabled ? undefined : openEditCommandModal}
/>
</View>
);

View file

@ -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<typeof ChecklistItemBottomSheet> {
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(<ChecklistItemBottomSheet {...props}/>, {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(<ChecklistItemBottomSheet {...props}/>, {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(<ChecklistItemBottomSheet {...props}/>, {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(<ChecklistItemBottomSheet {...props}/>, {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(<ChecklistItemBottomSheet {...props}/>, {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(<ChecklistItemBottomSheet {...props}/>, {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(<ChecklistItemBottomSheet {...props}/>);
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(<ChecklistItemBottomSheet {...props}/>, {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(<ChecklistItemBottomSheet {...props}/>, {database});
const bottomSheet = getByTestId('checklist_item_bottom_sheet');
expect(bottomSheet).toBeTruthy();
expect(bottomSheet).toHaveProp('item', apiItem);
expect(bottomSheet.props.assignee).toBeUndefined();
});
});
});

View file

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

View file

@ -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(<ChecklistItem {...props}/>, {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(<ChecklistItem {...props}/>, {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', () => {

View file

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

View file

@ -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) => <Text {...props}>{Screens.PLAYBOOK_RUN}</Text>);
jest.mock('@playbooks/screens/edit_command', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mocked(EditCommand).mockImplementation((props) => <Text {...props}>{Screens.PLAYBOOK_EDIT_COMMAND}</Text>);
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');
});
});

View file

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

View file

@ -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",