[MM-67007] [MM-67008] Allow Item renaming and add-edit description (#9378)

* allow renaming and add-edit description

* address commit reviews

* fix tests

* missing tests, fix a problem with rebase/conflict

* use Pressable

* address review comments
This commit is contained in:
Guillermo Vayá 2026-01-15 15:49:13 +01:00 committed by GitHub
parent a3248d2a41
commit 90135d8580
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1110 additions and 51 deletions

View file

@ -6,7 +6,7 @@ import {getPlaybookChecklistById} from '@playbooks/database/queries/checklist';
import {getPlaybookChecklistItemById} from '@playbooks/database/queries/item';
import TestHelper from '@test/test_helper';
import {updateChecklistItem, setChecklistItemCommand, setAssignee, setDueDate, renameChecklist, deleteChecklistItem} from './checklist';
import {updateChecklistItem, setChecklistItemCommand, setAssignee, setDueDate, renameChecklist, deleteChecklistItem, updateChecklistItemTitleAndDescription} from './checklist';
import type ServerDataOperator from '@database/operator/server_data_operator';
@ -460,3 +460,79 @@ describe('deleteChecklistItem', () => {
await Promise.all(testPromises);
});
});
describe('updateChecklistItemTitleAndDescription', () => {
it('should handle not found database', async () => {
const {error} = await updateChecklistItemTitleAndDescription('foo', 'itemid', 'New Title', 'New Description');
expect(error).toBeTruthy();
expect((error as Error).message).toContain('foo database not found');
});
it('should handle item not found', async () => {
const {error} = await updateChecklistItemTitleAndDescription(serverUrl, 'nonexistent', 'New Title', 'New Description');
expect(error).toBe('Item not found: nonexistent');
});
it('should handle database write errors', async () => {
const checklistId = 'updatetitledesc-checklist-write-error';
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 updateChecklistItemTitleAndDescription(serverUrl, item.id, 'New Title', 'New Description');
expect(error).toBeTruthy();
operator.database.write = originalWrite;
});
it('should update title and description successfully', async () => {
const checklistId = 'updatetitledesc-checklist-success';
const item = TestHelper.createPlaybookItem(checklistId, 0);
await operator.handlePlaybookChecklistItem({items: [{...item, checklist_id: checklistId}], prepareRecordsOnly: false});
const newTitle = 'Updated Item Title';
const newDescription = 'Updated Item Description';
const {data, error} = await updateChecklistItemTitleAndDescription(serverUrl, item.id, newTitle, newDescription);
expect(error).toBeUndefined();
expect(data).toBe(true);
const updated = await getPlaybookChecklistItemById(operator.database, item.id);
expect(updated).toBeDefined();
expect(updated!.title).toBe(newTitle);
expect(updated!.description).toBe(newDescription);
});
it('should update with empty description', async () => {
const checklistId = 'updatetitledesc-checklist-empty-desc';
const item = TestHelper.createPlaybookItem(checklistId, 0);
await operator.handlePlaybookChecklistItem({items: [{...item, checklist_id: checklistId}], prepareRecordsOnly: false});
const newTitle = 'Updated Item Title';
const {data, error} = await updateChecklistItemTitleAndDescription(serverUrl, item.id, newTitle, '');
expect(error).toBeUndefined();
expect(data).toBe(true);
const updated = await getPlaybookChecklistItemById(operator.database, item.id);
expect(updated).toBeDefined();
expect(updated!.title).toBe(newTitle);
expect(updated!.description).toBe('');
});
it('should update with undefined description', async () => {
const checklistId = 'updatetitledesc-checklist-undef-desc';
const item = TestHelper.createPlaybookItem(checklistId, 0);
await operator.handlePlaybookChecklistItem({items: [{...item, checklist_id: checklistId}], prepareRecordsOnly: false});
const newTitle = 'Updated Item Title';
const {data, error} = await updateChecklistItemTitleAndDescription(serverUrl, item.id, newTitle, undefined);
expect(error).toBeUndefined();
expect(data).toBe(true);
const updated = await getPlaybookChecklistItemById(operator.database, item.id);
expect(updated).toBeDefined();
expect(updated!.title).toBe(newTitle);
expect(updated!.description).toBe('');
});
});

View file

@ -134,3 +134,30 @@ export async function deleteChecklistItem(serverUrl: string, itemId: string) {
return {error};
}
}
export async function updateChecklistItemTitleAndDescription(serverUrl: string, itemId: string, title: string, description?: string) {
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const item = await getPlaybookChecklistItemById(database, itemId);
if (!item) {
return {error: `Item not found: ${itemId}`};
}
// Validate title is not empty or whitespace-only
if (!title || !title.trim()) {
return {error: 'Title cannot be empty or whitespace-only'};
}
await database.write(async () => {
item.update((i) => {
i.title = title.trim();
i.description = description?.trim() ?? '';
});
});
return {data: true};
} catch (error) {
logError('failed to update checklist item title and description', error);
return {error};
}
}

View file

@ -11,6 +11,7 @@ import {
setDueDate as localSetDueDate,
renameChecklist as localRenameChecklist,
deleteChecklistItem as localDeleteChecklistItem,
updateChecklistItemTitleAndDescription as localUpdateChecklistItemTitleAndDescription,
} from '@playbooks/actions/local/checklist';
import {handlePlaybookRuns} from '@playbooks/actions/local/run';
@ -25,6 +26,7 @@ import {
renameChecklist,
addChecklistItem,
deleteChecklistItem,
updateChecklistItemTitleAndDescription,
} from './checklist';
const serverUrl = 'baseHandler.test.com';
@ -47,6 +49,7 @@ const mockClient = {
addChecklistItem: jest.fn(),
deleteChecklistItem: jest.fn(),
fetchPlaybookRun: jest.fn(),
updateChecklistItem: jest.fn(),
};
jest.mock('@playbooks/actions/local/checklist');
@ -400,7 +403,7 @@ describe('checklist', () => {
it('should handle client error', async () => {
jest.spyOn(NetworkManager, 'getClient').mockImplementationOnce(throwFunc);
const result = await addChecklistItem(serverUrl, playbookRunId, checklistNumber, title);
const result = await addChecklistItem(serverUrl, playbookRunId, checklistNumber, {title});
expect(result).toBeDefined();
expect('error' in result && result.error).toBeDefined();
});
@ -408,10 +411,10 @@ describe('checklist', () => {
it('should handle API exception', async () => {
mockClient.addChecklistItem.mockImplementationOnce(throwFunc);
const result = await addChecklistItem(serverUrl, playbookRunId, checklistNumber, title);
const result = await addChecklistItem(serverUrl, playbookRunId, checklistNumber, {title});
expect(result).toBeDefined();
expect('error' in result && result.error).toBeDefined();
expect(mockClient.addChecklistItem).toHaveBeenCalledWith(playbookRunId, checklistNumber, title);
expect(mockClient.addChecklistItem).toHaveBeenCalledWith(playbookRunId, checklistNumber, {title});
});
it('should add checklist item successfully', async () => {
@ -420,11 +423,11 @@ describe('checklist', () => {
mockClient.fetchPlaybookRun.mockResolvedValueOnce(mockRun);
(handlePlaybookRuns as jest.Mock).mockResolvedValueOnce({data: true});
const result = await addChecklistItem(serverUrl, playbookRunId, checklistNumber, title);
const result = await addChecklistItem(serverUrl, playbookRunId, checklistNumber, {title});
expect(result).toBeDefined();
expect('error' in result ? result.error : undefined).toBeUndefined();
expect('data' in result && result.data).toBe(true);
expect(mockClient.addChecklistItem).toHaveBeenCalledWith(playbookRunId, checklistNumber, title);
expect(mockClient.addChecklistItem).toHaveBeenCalledWith(playbookRunId, checklistNumber, {title});
expect(mockClient.fetchPlaybookRun).toHaveBeenCalledWith(playbookRunId);
expect(handlePlaybookRuns).toHaveBeenCalledWith(serverUrl, [mockRun], false, true);
});
@ -436,11 +439,11 @@ describe('checklist', () => {
mockClient.fetchPlaybookRun.mockResolvedValueOnce(mockRun);
(handlePlaybookRuns as jest.Mock).mockResolvedValueOnce({data: true});
const result = await addChecklistItem(serverUrl, playbookRunId, checklistNumber, emptyTitle);
const result = await addChecklistItem(serverUrl, playbookRunId, checklistNumber, {title: emptyTitle});
expect(result).toBeDefined();
expect('error' in result ? result.error : undefined).toBeUndefined();
expect('data' in result && result.data).toBe(true);
expect(mockClient.addChecklistItem).toHaveBeenCalledWith(playbookRunId, checklistNumber, emptyTitle);
expect(mockClient.addChecklistItem).toHaveBeenCalledWith(playbookRunId, checklistNumber, {title: emptyTitle});
expect(mockClient.fetchPlaybookRun).toHaveBeenCalledWith(playbookRunId);
expect(handlePlaybookRuns).toHaveBeenCalledWith(serverUrl, [mockRun], false, true);
});
@ -486,4 +489,49 @@ describe('checklist', () => {
expect(localDeleteChecklistItem).toHaveBeenCalledWith(serverUrl, itemId);
});
});
describe('updateChecklistItemTitleAndDescription', () => {
const newTitle = 'New Item Title';
const newDescription = 'New Item Description';
const item: ChecklistItemInput = {title: newTitle, description: newDescription};
it('should handle client error', async () => {
jest.spyOn(NetworkManager, 'getClient').mockImplementationOnce(throwFunc);
const result = await updateChecklistItemTitleAndDescription(serverUrl, playbookRunId, itemId, checklistNumber, itemNumber, item);
expect(result.error).toBeDefined();
expect(mockClient.updateChecklistItem).not.toHaveBeenCalled();
expect(localUpdateChecklistItemTitleAndDescription).not.toHaveBeenCalled();
});
it('should handle API exception', async () => {
mockClient.updateChecklistItem.mockImplementationOnce(throwFunc);
const result = await updateChecklistItemTitleAndDescription(serverUrl, playbookRunId, itemId, checklistNumber, itemNumber, item);
expect(result.error).toBeDefined();
expect(mockClient.updateChecklistItem).toHaveBeenCalledWith(playbookRunId, checklistNumber, itemNumber, item);
expect(localUpdateChecklistItemTitleAndDescription).not.toHaveBeenCalled();
});
it('should update checklist item title and description successfully', async () => {
mockClient.updateChecklistItem.mockResolvedValueOnce({});
const result = await updateChecklistItemTitleAndDescription(serverUrl, playbookRunId, itemId, checklistNumber, itemNumber, item);
expect(result.error).toBeUndefined();
expect(result.data).toBe(true);
expect(mockClient.updateChecklistItem).toHaveBeenCalledWith(playbookRunId, checklistNumber, itemNumber, item);
expect(localUpdateChecklistItemTitleAndDescription).toHaveBeenCalledWith(serverUrl, itemId, newTitle, newDescription);
});
it('should update with empty description', async () => {
mockClient.updateChecklistItem.mockResolvedValueOnce({});
const itemWithEmptyDesc: ChecklistItemInput = {title: newTitle, description: ''};
const result = await updateChecklistItemTitleAndDescription(serverUrl, playbookRunId, itemId, checklistNumber, itemNumber, itemWithEmptyDesc);
expect(result.error).toBeUndefined();
expect(result.data).toBe(true);
expect(mockClient.updateChecklistItem).toHaveBeenCalledWith(playbookRunId, checklistNumber, itemNumber, itemWithEmptyDesc);
expect(localUpdateChecklistItemTitleAndDescription).toHaveBeenCalledWith(serverUrl, itemId, newTitle, '');
});
});
});

View file

@ -11,6 +11,7 @@ import {
setDueDate as localSetDueDate,
renameChecklist as localRenameChecklist,
deleteChecklistItem as localDeleteChecklistItem,
updateChecklistItemTitleAndDescription as localUpdateChecklistItemTitleAndDescription,
} from '@playbooks/actions/local/checklist';
import {handlePlaybookRuns} from '@playbooks/actions/local/run';
import {getFullErrorMessage} from '@utils/errors';
@ -187,11 +188,11 @@ export const addChecklistItem = async (
serverUrl: string,
playbookRunId: string,
checklistNumber: number,
title: string,
item: ChecklistItemInput,
) => {
try {
const client = NetworkManager.getClient(serverUrl);
await client.addChecklistItem(playbookRunId, checklistNumber, title);
await client.addChecklistItem(playbookRunId, checklistNumber, item);
// Fetch and sync the entire run to get the created item with server-generated ID
const run = await client.fetchPlaybookRun(playbookRunId);
@ -229,3 +230,24 @@ export const deleteChecklistItem = async (
return {error};
}
};
export const updateChecklistItemTitleAndDescription = async (
serverUrl: string,
playbookRunId: string,
itemId: string,
checklistNumber: number,
itemNumber: number,
item: ChecklistItemInput,
) => {
try {
const client = NetworkManager.getClient(serverUrl);
await client.updateChecklistItem(playbookRunId, checklistNumber, itemNumber, item);
await localUpdateChecklistItemTitleAndDescription(serverUrl, itemId, item.title, item.description);
return {data: true};
} catch (error) {
logDebug('error on updateChecklistItemTitleAndDescription', getFullErrorMessage(error));
forceLogoutIfNecessary(serverUrl, error);
return {error};
}
};

View file

@ -1040,16 +1040,30 @@ describe('renameChecklist', () => {
});
describe('addChecklistItem', () => {
test('should add item successfully', async () => {
test('should add item with title only', async () => {
const playbookRunId = 'run123';
const checklistNum = 1;
const title = 'New Item';
const item: ChecklistItemInput = {title: 'New Item'};
const expectedUrl = `/plugins/playbooks/api/v0/runs/${playbookRunId}/checklists/${checklistNum}/add`;
const expectedOptions = {method: 'post', body: {title}};
const expectedOptions = {method: 'post', body: item};
jest.mocked(client.doFetch).mockResolvedValue(undefined);
await client.addChecklistItem(playbookRunId, checklistNum, title);
await client.addChecklistItem(playbookRunId, checklistNum, item);
expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions);
});
test('should add item with title and description', async () => {
const playbookRunId = 'run123';
const checklistNum = 1;
const item: ChecklistItemInput = {title: 'New Item', description: 'Item description'};
const expectedUrl = `/plugins/playbooks/api/v0/runs/${playbookRunId}/checklists/${checklistNum}/add`;
const expectedOptions = {method: 'post', body: item};
jest.mocked(client.doFetch).mockResolvedValue(undefined);
await client.addChecklistItem(playbookRunId, checklistNum, item);
expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions);
});
@ -1057,28 +1071,86 @@ describe('addChecklistItem', () => {
test('should handle network errors', async () => {
const playbookRunId = 'run123';
const checklistNum = 1;
const title = 'New Item';
const item: ChecklistItemInput = {title: 'New Item'};
jest.mocked(client.doFetch).mockRejectedValue(new Error('Network error'));
await expect(client.addChecklistItem(playbookRunId, checklistNum, title)).rejects.toThrow('Network error');
await expect(client.addChecklistItem(playbookRunId, checklistNum, item)).rejects.toThrow('Network error');
});
test('should handle invalid checklist number', async () => {
const playbookRunId = 'run123';
const checklistNum = -1;
const title = 'New Item';
const item: ChecklistItemInput = {title: 'New Item'};
const expectedUrl = `/plugins/playbooks/api/v0/runs/${playbookRunId}/checklists/${checklistNum}/add`;
const expectedOptions = {method: 'post', body: {title}};
const expectedOptions = {method: 'post', body: item};
jest.mocked(client.doFetch).mockResolvedValue(undefined);
await client.addChecklistItem(playbookRunId, checklistNum, title);
await client.addChecklistItem(playbookRunId, checklistNum, item);
expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions);
});
});
describe('updateChecklistItem', () => {
test('should update item with title only', async () => {
const playbookRunId = 'run123';
const checklistNum = 1;
const itemNum = 2;
const item: ChecklistItemInput = {title: 'Updated Title'};
const expectedUrl = `/plugins/playbooks/api/v0/runs/${playbookRunId}/checklists/${checklistNum}/item/${itemNum}`;
const expectedOptions = {method: 'put', body: item};
jest.mocked(client.doFetch).mockResolvedValue(undefined);
await client.updateChecklistItem(playbookRunId, checklistNum, itemNum, item);
expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions);
});
test('should update item with title and description', async () => {
const playbookRunId = 'run123';
const checklistNum = 1;
const itemNum = 2;
const item: ChecklistItemInput = {title: 'Updated Title', description: 'Updated description'};
const expectedUrl = `/plugins/playbooks/api/v0/runs/${playbookRunId}/checklists/${checklistNum}/item/${itemNum}`;
const expectedOptions = {method: 'put', body: item};
jest.mocked(client.doFetch).mockResolvedValue(undefined);
await client.updateChecklistItem(playbookRunId, checklistNum, itemNum, item);
expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions);
});
test('should handle zero indices', async () => {
const playbookRunId = 'run123';
const checklistNum = 0;
const itemNum = 0;
const item: ChecklistItemInput = {title: 'Updated Title'};
const expectedUrl = `/plugins/playbooks/api/v0/runs/${playbookRunId}/checklists/${checklistNum}/item/${itemNum}`;
const expectedOptions = {method: 'put', body: item};
jest.mocked(client.doFetch).mockResolvedValue(undefined);
await client.updateChecklistItem(playbookRunId, checklistNum, itemNum, item);
expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions);
});
test('should handle network errors', async () => {
const playbookRunId = 'run123';
const checklistNum = 1;
const itemNum = 2;
const item: ChecklistItemInput = {title: 'Updated Title'};
jest.mocked(client.doFetch).mockRejectedValue(new Error('Network error'));
await expect(client.updateChecklistItem(playbookRunId, checklistNum, itemNum, item)).rejects.toThrow('Network error');
});
});
describe('fetchRunPropertyFields', () => {
test('should fetch property fields without updatedSince parameter', async () => {
const runId = 'run123';

View file

@ -28,8 +28,9 @@ export interface ClientPlaybooksMix {
restoreChecklistItem: (playbookRunID: string, checklistNum: number, itemNum: number) => Promise<void>;
setAssignee: (playbookRunId: string, checklistNum: number, itemNum: number, assigneeId?: string) => Promise<void>;
setDueDate: (playbookRunId: string, checklistNum: number, itemNum: number, date?: number) => Promise<void>;
addChecklistItem: (playbookRunId: string, checklistNum: number, title: string) => Promise<void>;
addChecklistItem: (playbookRunId: string, checklistNum: number, item: ChecklistItemInput) => Promise<void>;
deleteChecklistItem: (playbookRunId: string, checklistNum: number, itemNum: number) => Promise<void>;
updateChecklistItem: (playbookRunId: string, checklistNum: number, itemNum: number, item: ChecklistItemInput) => Promise<void>;
renameChecklist: (playbookRunId: string, checklistNumber: number, newName: string) => Promise<void>;
@ -210,10 +211,17 @@ const ClientPlaybooks = <TBase extends Constructor<ClientBase>>(superclass: TBas
);
};
addChecklistItem = async (playbookRunId: string, checklistNum: number, title: string) => {
addChecklistItem = async (playbookRunId: string, checklistNum: number, item: ChecklistItemInput) => {
await this.doFetch(
`${this.getPlaybookRunRoute(playbookRunId)}/checklists/${checklistNum}/add`,
{method: 'post', body: {title}},
{method: 'post', body: item},
);
};
updateChecklistItem = async (playbookRunId: string, checklistNum: number, itemNum: number, item: ChecklistItemInput) => {
await this.doFetch(
`${this.getPlaybookRunRoute(playbookRunId)}/checklists/${checklistNum}/item/${itemNum}`,
{method: 'put', body: item},
);
};

View file

@ -8,6 +8,7 @@ export const PLAYBOOK_EDIT_COMMAND = 'PlaybookEditCommand';
export const PLAYBOOK_POST_UPDATE = 'PlaybookPostUpdate';
export const PLAYBOOK_RENAME_CHECKLIST = 'PlaybookRenameChecklist';
export const PLAYBOOK_ADD_CHECKLIST_ITEM = 'PlaybookAddChecklistItem';
export const PLAYBOOK_EDIT_CHECKLIST_ITEM = 'PlaybookEditChecklistItem';
export const PLAYBOOK_RENAME_RUN = 'PlaybookRenameRun';
export const PLAYBOOK_SELECT_USER = 'PlaybookSelectUser';
export const PLAYBOOKS_SELECT_DATE = 'PlaybooksSelectDate';
@ -23,6 +24,7 @@ export default {
PLAYBOOK_POST_UPDATE,
PLAYBOOK_RENAME_CHECKLIST,
PLAYBOOK_ADD_CHECKLIST_ITEM,
PLAYBOOK_EDIT_CHECKLIST_ITEM,
PLAYBOOK_RENAME_RUN,
PLAYBOOK_SELECT_USER,
PLAYBOOKS_SELECT_DATE,

View file

@ -12,6 +12,7 @@ import EditCommand from './edit_command';
import ParticipantPlaybooks from './participant_playbooks';
import PlaybookRun from './playbook_run';
import AddChecklistItemBottomSheet from './playbook_run/checklist/add_checklist_item_bottom_sheet';
import EditChecklistItemBottomSheet from './playbook_run/checklist/edit_checklist_item_bottom_sheet';
import RenameChecklistBottomSheet from './playbook_run/checklist/rename_checklist_bottom_sheet';
import RenamePlaybookRunBottomSheet from './playbook_run/rename_playbook_run_bottom_sheet';
import PlaybookRuns from './playbooks_runs';
@ -101,6 +102,12 @@ jest.mock('@playbooks/screens/playbook_run/checklist/add_checklist_item_bottom_s
}));
jest.mocked(AddChecklistItemBottomSheet).mockImplementation((props) => <Text {...props}>{Screens.PLAYBOOK_ADD_CHECKLIST_ITEM}</Text>);
jest.mock('@playbooks/screens/playbook_run/checklist/edit_checklist_item_bottom_sheet', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mocked(EditChecklistItemBottomSheet).mockImplementation((props) => <Text {...props}>{Screens.PLAYBOOK_EDIT_CHECKLIST_ITEM}</Text>);
jest.mock('@playbooks/screens/playbook_run/rename_playbook_run_bottom_sheet', () => ({
__esModule: true,
default: jest.fn(),

View file

@ -20,6 +20,8 @@ export function loadPlaybooksScreen(screenName: string | number) {
return withServerDatabase(require('@playbooks/screens/playbook_run/checklist/rename_checklist_bottom_sheet').default);
case Screens.PLAYBOOK_ADD_CHECKLIST_ITEM:
return withServerDatabase(require('@playbooks/screens/playbook_run/checklist/add_checklist_item_bottom_sheet').default);
case Screens.PLAYBOOK_EDIT_CHECKLIST_ITEM:
return withServerDatabase(require('@playbooks/screens/playbook_run/checklist/edit_checklist_item_bottom_sheet').default);
case Screens.PLAYBOOK_RENAME_RUN:
return withServerDatabase(require('@playbooks/screens/playbook_run/rename_playbook_run_bottom_sheet').default);
case Screens.PLAYBOOK_SELECT_USER:

View file

@ -7,7 +7,7 @@ import {goToScreen} from '@screens/navigation';
import TestHelper from '@test/test_helper';
import {changeOpacity} from '@utils/theme';
import {goToPlaybookRuns, goToPlaybookRun, goToParticipantPlaybooks, goToPlaybookRunWithChannelSwitch, goToEditCommand, goToSelectUser, goToSelectDate, goToPostUpdate, goToSelectPlaybook, goToStartARun, goToRenameChecklist, goToAddChecklistItem, goToRenamePlaybookRun, goToCreateQuickChecklist} from './navigation';
import {goToPlaybookRuns, goToPlaybookRun, goToParticipantPlaybooks, goToPlaybookRunWithChannelSwitch, goToEditCommand, goToSelectUser, goToSelectDate, goToPostUpdate, goToSelectPlaybook, goToStartARun, goToRenameChecklist, goToAddChecklistItem, goToEditChecklistItem, goToRenamePlaybookRun, goToCreateQuickChecklist} from './navigation';
jest.mock('@screens/navigation', () => ({
goToScreen: jest.fn(),
@ -542,6 +542,67 @@ describe('Playbooks Navigation', () => {
});
});
describe('goToEditChecklistItem', () => {
it('should navigate to edit checklist item screen with correct parameters', async () => {
const currentTitle = 'Test Task';
const currentDescription = 'Test Description';
const onSave = jest.fn();
await goToEditChecklistItem(mockIntl, Preferences.THEMES.denim, 'Run 1', currentTitle, currentDescription, onSave);
expect(mockIntl.formatMessage).toHaveBeenCalledWith({
id: 'playbooks.checklist_item.edit.title',
defaultMessage: 'Edit Task',
});
expect(goToScreen).toHaveBeenCalledWith(
Screens.PLAYBOOK_EDIT_CHECKLIST_ITEM,
'Edit Task',
{
currentTitle,
currentDescription,
onSave,
},
{
topBar: {
subtitle: {
text: 'Run 1',
color: changeOpacity(Preferences.THEMES.denim.sidebarHeaderTextColor, 0.72),
},
},
},
);
});
it('should navigate to edit checklist item screen with undefined description', async () => {
const currentTitle = 'Test Task';
const onSave = jest.fn();
await goToEditChecklistItem(mockIntl, Preferences.THEMES.denim, 'Run 1', currentTitle, undefined, onSave);
expect(mockIntl.formatMessage).toHaveBeenCalledWith({
id: 'playbooks.checklist_item.edit.title',
defaultMessage: 'Edit Task',
});
expect(goToScreen).toHaveBeenCalledWith(
Screens.PLAYBOOK_EDIT_CHECKLIST_ITEM,
'Edit Task',
{
currentTitle,
currentDescription: undefined,
onSave,
},
{
topBar: {
subtitle: {
text: 'Run 1',
color: changeOpacity(Preferences.THEMES.denim.sidebarHeaderTextColor, 0.72),
},
},
},
);
});
});
describe('goToRenamePlaybookRun', () => {
it('should navigate to rename playbook run screen with correct parameters', async () => {
const currentTitle = 'Playbook Run Title';

View file

@ -103,7 +103,7 @@ export async function goToAddChecklistItem(
intl: IntlShape,
theme: Theme,
runName: string,
onSave: (title: string) => void,
onSave: (item: ChecklistItemInput) => void,
) {
const title = intl.formatMessage({id: 'playbooks.checklist_item.add.title', defaultMessage: 'New Task'});
const options = getSubtitleOptions(theme, runName);
@ -112,6 +112,23 @@ export async function goToAddChecklistItem(
}, options);
}
export async function goToEditChecklistItem(
intl: IntlShape,
theme: Theme,
runName: string,
currentTitle: string,
currentDescription: string | undefined,
onSave: (item: ChecklistItemInput) => void,
) {
const title = intl.formatMessage({id: 'playbooks.checklist_item.edit.title', defaultMessage: 'Edit Task'});
const options = getSubtitleOptions(theme, runName);
goToScreen(Screens.PLAYBOOK_EDIT_CHECKLIST_ITEM, title, {
currentTitle,
currentDescription,
onSave,
}, options);
}
export async function goToRenamePlaybookRun(
intl: IntlShape,
theme: Theme,

View file

@ -66,9 +66,11 @@ describe('AddChecklistItemBottomSheet', () => {
expect(input.props.value).toBe('');
expect(input.props.autoFocus).toBe(true);
// Check label is rendered
const label = getByText('Task name');
expect(label).toBeTruthy();
// Check labels are rendered
const titleLabel = getByText('Task name');
expect(titleLabel).toBeTruthy();
const descriptionLabel = getByText('Description');
expect(descriptionLabel).toBeTruthy();
});
it('should set up navigation buttons on mount', () => {
@ -122,6 +124,45 @@ describe('AddChecklistItemBottomSheet', () => {
expect(input.props.value).toBe(newTitle);
});
it('should update description when text input changes', () => {
const props = getBaseProps();
const {getByTestId} = renderWithIntlAndTheme(<AddChecklistItemBottomSheet {...props}/>);
const descriptionInput = getByTestId('playbooks.checklist_item.add.description_input');
const newDescription = 'Task description';
act(() => {
fireEvent.changeText(descriptionInput, newDescription);
});
expect(descriptionInput.props.value).toBe(newDescription);
});
it('should call onSave with description when provided', () => {
const props = getBaseProps();
const {getByTestId} = renderWithIntlAndTheme(<AddChecklistItemBottomSheet {...props}/>);
const titleInput = getByTestId('playbooks.checklist_item.add.input');
const descriptionInput = getByTestId('playbooks.checklist_item.add.description_input');
const taskTitle = 'New Task';
const taskDescription = 'Task description';
// Set title and description
act(() => {
fireEvent.changeText(titleInput, taskTitle);
fireEvent.changeText(descriptionInput, taskDescription);
});
// Trigger save button press
const lastCall = getLastCallForButton(jest.mocked(useNavButtonPressed), 'add-checklist-item');
const saveCallback = lastCall[2];
act(() => {
saveCallback();
});
expect(mockOnSave).toHaveBeenCalledWith({title: taskTitle, description: taskDescription});
});
it('should enable save button when title has content', () => {
const props = getBaseProps();
const {getByTestId} = renderWithIntlAndTheme(<AddChecklistItemBottomSheet {...props}/>);
@ -221,7 +262,7 @@ describe('AddChecklistItemBottomSheet', () => {
saveCallback();
});
expect(mockOnSave).toHaveBeenCalledWith(taskTitle);
expect(mockOnSave).toHaveBeenCalledWith({title: taskTitle});
expect(Keyboard.dismiss).toHaveBeenCalled();
expect(popTopScreen).toHaveBeenCalledWith(componentId);
});
@ -245,7 +286,7 @@ describe('AddChecklistItemBottomSheet', () => {
saveCallback();
});
expect(mockOnSave).toHaveBeenCalledWith('Task with spaces');
expect(mockOnSave).toHaveBeenCalledWith({title: 'Task with spaces'});
expect(mockOnSave).not.toHaveBeenCalledWith(taskTitle);
});

View file

@ -16,7 +16,7 @@ import type {AvailableScreens} from '@typings/screens/navigation';
type Props = {
componentId: AvailableScreens;
onSave: (title: string) => void;
onSave: (item: ChecklistItemInput) => void;
}
const SAVE_BUTTON_ID = 'add-checklist-item';
@ -31,6 +31,7 @@ const styles = StyleSheet.create({
flex: 1,
paddingVertical: 32,
paddingHorizontal: 20,
gap: 16,
},
});
@ -43,6 +44,7 @@ const AddChecklistItemBottomSheet = ({
const theme = useTheme();
const [title, setTitle] = useState<string>('');
const [description, setDescription] = useState<string>('');
const canSave = useMemo(() => {
return Boolean(title.trim().length);
@ -72,15 +74,19 @@ const AddChecklistItemBottomSheet = ({
const handleSave = useCallback(() => {
if (canSave) {
onSave(title.trim());
onSave({
title: title.trim(),
...(description.trim() && {description: description.trim()}),
});
close(componentId);
}
}, [canSave, title, componentId, onSave]);
}, [canSave, title, description, componentId, onSave]);
useNavButtonPressed(SAVE_BUTTON_ID, componentId, handleSave, [handleSave]);
useAndroidHardwareBackHandler(componentId, handleClose);
const label = formatMessage({id: 'playbooks.checklist_item.add.label', defaultMessage: 'Task name'});
const titleLabel = formatMessage({id: 'playbooks.checklist_item.add.label', defaultMessage: 'Task name'});
const descriptionLabel = formatMessage({id: 'playbooks.checklist_item.add.description_label', defaultMessage: 'Description'});
return (
<View
@ -88,13 +94,22 @@ const AddChecklistItemBottomSheet = ({
style={styles.container}
>
<FloatingTextInput
label={label}
label={titleLabel}
onChangeText={setTitle}
testID='playbooks.checklist_item.add.input'
value={title}
theme={theme}
autoFocus={true}
/>
<FloatingTextInput
label={descriptionLabel}
onChangeText={setDescription}
testID='playbooks.checklist_item.add.description_input'
value={description}
theme={theme}
multiline={true}
multilineInputHeight={100}
/>
</View>
);
};

View file

@ -386,7 +386,7 @@ describe('Checklist', () => {
const onAdd = jest.mocked(goToAddChecklistItem).mock.calls[0][3];
await act(async () => {
await onAdd('New Item');
await onAdd({title: 'New Item'});
});
await waitFor(() => {
@ -394,7 +394,7 @@ describe('Checklist', () => {
serverUrl,
props.playbookRunId,
props.checklistNumber,
'New Item',
{title: 'New Item'},
);
expect(showPlaybookErrorSnackbar).toHaveBeenCalled();
expect(logError).toHaveBeenCalledWith('error on addChecklistItem', expect.any(String));
@ -404,7 +404,7 @@ describe('Checklist', () => {
it('handles successful add item without errors', async () => {
jest.mocked(addChecklistItem).mockResolvedValueOnce({data: true} as {data: boolean});
jest.mocked(goToAddChecklistItem).mockImplementation(async (intl, theme, playbookRunName, onAdd) => {
onAdd('New Item');
onAdd({title: 'New Item'});
});
const props = getBaseProps();
@ -423,7 +423,7 @@ describe('Checklist', () => {
serverUrl,
props.playbookRunId,
props.checklistNumber,
'New Item',
{title: 'New Item'},
);
expect(showPlaybookErrorSnackbar).not.toHaveBeenCalled();
expect(logError).not.toHaveBeenCalled();

View file

@ -157,8 +157,8 @@ const Checklist = ({
goToRenameChecklist(intl, theme, playbookRunName, checklist.title, handleRename);
}, [intl, theme, playbookRunName, checklist.title, handleRename]);
const handleAddItem = useCallback(async (title: string) => {
const res = await addChecklistItem(serverUrl, playbookRunId, checklistNumber, title);
const handleAddItem = useCallback(async (item: ChecklistItemInput) => {
const res = await addChecklistItem(serverUrl, playbookRunId, checklistNumber, item);
if ('error' in res && res.error) {
showPlaybookErrorSnackbar();
logError('error on addChecklistItem', getFullErrorMessage(res.error));

View file

@ -11,8 +11,8 @@ import OptionBox from '@components/option_box';
import OptionItem from '@components/option_item';
import {Preferences} from '@constants';
import {useIsTablet} from '@hooks/device';
import {setAssignee, setChecklistItemCommand, setDueDate, deleteChecklistItem} from '@playbooks/actions/remote/checklist';
import {goToEditCommand, goToSelectDate, goToSelectUser} from '@playbooks/screens/navigation';
import {setAssignee, setChecklistItemCommand, setDueDate, deleteChecklistItem, updateChecklistItemTitleAndDescription} from '@playbooks/actions/remote/checklist';
import {goToEditChecklistItem, goToEditCommand, goToSelectDate, goToSelectUser} from '@playbooks/screens/navigation';
import {dismissBottomSheet, openUserProfileModal} from '@screens/navigation';
import {renderWithIntl} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
@ -837,4 +837,110 @@ describe('ChecklistItemBottomSheet', () => {
});
});
});
describe('edit button', () => {
it('should render edit button when isDisabled is false', () => {
const props = getBaseProps();
props.isDisabled = false;
const {getByTestId} = renderWithIntl(<ChecklistItemBottomSheet {...props}/>);
expect(getByTestId('checklist_item.edit_button')).toBeVisible();
});
it('should not render edit button when isDisabled is true', () => {
const props = getBaseProps();
props.isDisabled = true;
const {queryByTestId} = renderWithIntl(<ChecklistItemBottomSheet {...props}/>);
expect(queryByTestId('checklist_item.edit_button')).toBeNull();
});
it('should open edit modal when edit button is pressed', () => {
const props = getBaseProps();
props.item = TestHelper.fakePlaybookChecklistItemModel({
id: 'item-1',
title: 'Test Checklist Item',
description: 'This is a test description',
state: '',
command: 'test command',
});
const {getByTestId} = renderWithIntl(<ChecklistItemBottomSheet {...props}/>);
const editButton = getByTestId('checklist_item.edit_button');
fireEvent.press(editButton);
expect(goToEditChecklistItem).toHaveBeenCalledWith(
expect.anything(), // intl
expect.anything(), // theme
'Run 1',
'Test Checklist Item',
'This is a test description',
expect.any(Function),
);
});
it('should open edit modal with undefined description when item has no description', () => {
const props = getBaseProps();
props.item = TestHelper.fakePlaybookChecklistItemModel({
...mockItem,
description: '',
});
const {getByTestId} = renderWithIntl(<ChecklistItemBottomSheet {...props}/>);
const editButton = getByTestId('checklist_item.edit_button');
fireEvent.press(editButton);
expect(goToEditChecklistItem).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
'Run 1',
'Test Checklist Item',
undefined,
expect.any(Function),
);
});
it('should call updateChecklistItemTitleAndDescription when edit is saved', async () => {
jest.mocked(updateChecklistItemTitleAndDescription).mockResolvedValue({data: true});
const props = getBaseProps();
props.checklistNumber = 2;
props.itemNumber = 3;
const {getByTestId} = renderWithIntl(<ChecklistItemBottomSheet {...props}/>);
const editButton = getByTestId('checklist_item.edit_button');
fireEvent.press(editButton);
const handleEditItem = jest.mocked(goToEditChecklistItem).mock.calls[0][5];
await act(async () => {
handleEditItem({title: 'New Title', description: 'New Description'});
});
expect(updateChecklistItemTitleAndDescription).toHaveBeenCalledWith(
'server-url',
'run-1',
'item-1',
2,
3,
{title: 'New Title', description: 'New Description'},
);
});
it('should show error snackbar when updateChecklistItemTitleAndDescription fails', async () => {
jest.mocked(updateChecklistItemTitleAndDescription).mockResolvedValue({error: 'Update failed'});
const props = getBaseProps();
const {getByTestId} = renderWithIntl(<ChecklistItemBottomSheet {...props}/>);
const editButton = getByTestId('checklist_item.edit_button');
fireEvent.press(editButton);
const handleEditItem = jest.mocked(goToEditChecklistItem).mock.calls[0][5];
await act(async () => {
handleEditItem({title: 'New Title', description: 'New Description'});
});
await waitFor(() => {
expect(showPlaybookErrorSnackbar).toHaveBeenCalled();
});
});
});
});

View file

@ -11,8 +11,8 @@ 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 {setAssignee, setChecklistItemCommand, setDueDate, deleteChecklistItem} from '@playbooks/actions/remote/checklist';
import {goToEditCommand, goToSelectDate, goToSelectUser} from '@playbooks/screens/navigation';
import {setAssignee, setChecklistItemCommand, setDueDate, deleteChecklistItem, updateChecklistItemTitleAndDescription} from '@playbooks/actions/remote/checklist';
import {goToEditChecklistItem, goToEditCommand, goToSelectDate, goToSelectUser} from '@playbooks/screens/navigation';
import {getDueDateString} from '@playbooks/utils/time';
import {dismissBottomSheet, openUserProfileModal} from '@screens/navigation';
import {showPlaybookErrorSnackbar} from '@utils/snack_bar';
@ -121,6 +121,20 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => ({
alignItems: 'flex-start',
gap: 12,
},
titleContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
flex: 1,
},
editIcon: {
fontSize: 18,
color: changeOpacity(theme.centerChannelColor, 0.56),
paddingHorizontal: 4,
},
editIconPressed: {
opacity: 0.2,
},
taskTitle: {
...typography('Body', 300, 'Regular'),
color: theme.centerChannelColor,
@ -333,6 +347,18 @@ const ChecklistItemBottomSheet = ({
);
}, [assignee?.id, handleRemove, handleSelect, intl, participantIds, runName, theme]);
const handleEditItem = useCallback(async (itemInput: ChecklistItemInput) => {
const res = await updateChecklistItemTitleAndDescription(serverUrl, runId, item.id, checklistNumber, itemNumber, itemInput);
if (res.error) {
showPlaybookErrorSnackbar();
}
}, [checklistNumber, item.id, itemNumber, runId, serverUrl]);
const openEditItemModal = useCallback(() => {
const itemDescription = ('description' in item && item.description) || undefined;
goToEditChecklistItem(intl, theme, runName, item.title, itemDescription, handleEditItem);
}, [intl, theme, runName, item, handleEditItem]);
const renderTaskDetails = () => (
<View style={styles.taskDetailsContainer}>
<OptionItem
@ -431,14 +457,28 @@ const ChecklistItemBottomSheet = ({
checked={isChecked}
onPress={handleCheck}
/>
<View style={styles.flex}>
<Text style={styles.taskTitle}>
{item.title}
</Text>
{Boolean(item.description) && (
<Text style={styles.taskDescription}>
{item.description}
<View style={styles.titleContainer}>
<View style={styles.flex}>
<Text style={styles.taskTitle}>
{item.title}
</Text>
{(item.description) && (
<Text style={styles.taskDescription}>
{item.description}
</Text>
)}
</View>
{!isDisabled && (
<Pressable
onPress={openEditItemModal}
style={({pressed}) => pressed && styles.editIconPressed}
testID='checklist_item.edit_button'
>
<CompassIcon
name='pencil-outline'
style={styles.editIcon}
/>
</Pressable>
)}
</View>
</View>

View file

@ -0,0 +1,379 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {act, fireEvent} from '@testing-library/react-native';
import React from 'react';
import {Keyboard} from 'react-native';
import {Preferences, Screens} from '@constants';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import useNavButtonPressed from '@hooks/navigation_button_pressed';
import {buildNavigationButton, popTopScreen, setButtons} from '@screens/navigation';
import {renderWithIntlAndTheme} from '@test/intl-test-helper';
import {getLastCallForButton} from '@test/mock_helpers';
import EditChecklistItemBottomSheet from './edit_checklist_item_bottom_sheet';
jest.mock('@screens/navigation', () => ({
buildNavigationButton: jest.fn(),
popTopScreen: jest.fn(),
setButtons: jest.fn(),
}));
jest.mock('@hooks/navigation_button_pressed', () => jest.fn());
jest.mock('@hooks/android_back_handler', () => jest.fn());
jest.mock('@managers/security_manager', () => ({
getShieldScreenId: jest.fn((id) => `shield-${id}`),
}));
describe('EditChecklistItemBottomSheet', () => {
const componentId = Screens.PLAYBOOK_EDIT_CHECKLIST_ITEM;
const mockOnSave = jest.fn();
const mockRightButton = {
id: 'save-checklist-item',
enabled: false,
color: Preferences.THEMES.denim.sidebarHeaderTextColor,
};
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(buildNavigationButton).mockReturnValue(mockRightButton as any);
});
function getBaseProps() {
return {
componentId,
currentTitle: 'Current Task',
currentDescription: 'Current description',
onSave: mockOnSave,
};
}
it('should render correctly with default props', () => {
const props = getBaseProps();
const {getByTestId, getByText} = renderWithIntlAndTheme(<EditChecklistItemBottomSheet {...props}/>);
const titleInput = getByTestId('playbooks.checklist_item.edit.title_input');
expect(titleInput).toBeTruthy();
expect(titleInput.props.value).toBe('Current Task');
expect(titleInput.props.autoFocus).toBe(true);
const descriptionInput = getByTestId('playbooks.checklist_item.edit.description_input');
expect(descriptionInput).toBeTruthy();
expect(descriptionInput.props.value).toBe('Current description');
// Check labels are rendered
const titleLabel = getByText('Task name');
expect(titleLabel).toBeTruthy();
const descriptionLabel = getByText('Description');
expect(descriptionLabel).toBeTruthy();
});
it('should render correctly without description', () => {
const props = {
...getBaseProps(),
currentDescription: undefined,
};
const {getByTestId} = renderWithIntlAndTheme(<EditChecklistItemBottomSheet {...props}/>);
const descriptionInput = getByTestId('playbooks.checklist_item.edit.description_input');
expect(descriptionInput.props.value).toBe('');
});
it('should set up navigation buttons on mount', () => {
const props = getBaseProps();
renderWithIntlAndTheme(<EditChecklistItemBottomSheet {...props}/>);
expect(buildNavigationButton).toHaveBeenCalledWith(
'save-checklist-item',
'playbooks.checklist_item.edit.button',
undefined,
'Save',
);
expect(setButtons).toHaveBeenCalledWith(componentId, {
rightButtons: [mockRightButton],
});
});
it('should register navigation button press handler', () => {
const props = getBaseProps();
renderWithIntlAndTheme(<EditChecklistItemBottomSheet {...props}/>);
expect(useNavButtonPressed).toHaveBeenCalledWith(
'save-checklist-item',
componentId,
expect.any(Function),
[expect.any(Function)],
);
});
it('should register Android back handler', () => {
const props = getBaseProps();
renderWithIntlAndTheme(<EditChecklistItemBottomSheet {...props}/>);
expect(useAndroidHardwareBackHandler).toHaveBeenCalledWith(
componentId,
expect.any(Function),
);
});
it('should update title when text input changes', () => {
const props = getBaseProps();
const {getByTestId} = renderWithIntlAndTheme(<EditChecklistItemBottomSheet {...props}/>);
const input = getByTestId('playbooks.checklist_item.edit.title_input');
const newTitle = 'New Task Title';
act(() => {
fireEvent.changeText(input, newTitle);
});
expect(input.props.value).toBe(newTitle);
});
it('should update description when text input changes', () => {
const props = getBaseProps();
const {getByTestId} = renderWithIntlAndTheme(<EditChecklistItemBottomSheet {...props}/>);
const descriptionInput = getByTestId('playbooks.checklist_item.edit.description_input');
const newDescription = 'New description';
act(() => {
fireEvent.changeText(descriptionInput, newDescription);
});
expect(descriptionInput.props.value).toBe(newDescription);
});
it('should enable save button when title is changed', () => {
const props = getBaseProps();
const {getByTestId} = renderWithIntlAndTheme(<EditChecklistItemBottomSheet {...props}/>);
const input = getByTestId('playbooks.checklist_item.edit.title_input');
// Initially disabled (no changes)
expect(mockRightButton.enabled).toBe(false);
// Update with different title
act(() => {
fireEvent.changeText(input, 'Different Title');
});
// Button should be enabled now
const updatedButton = {
...mockRightButton,
enabled: true,
};
expect(setButtons).toHaveBeenCalledWith(componentId, {
rightButtons: [updatedButton],
});
});
it('should enable save button when description is changed', () => {
const props = getBaseProps();
const {getByTestId} = renderWithIntlAndTheme(<EditChecklistItemBottomSheet {...props}/>);
const descriptionInput = getByTestId('playbooks.checklist_item.edit.description_input');
// Initially disabled (no changes)
expect(mockRightButton.enabled).toBe(false);
// Update with different description
act(() => {
fireEvent.changeText(descriptionInput, 'Different description');
});
// Button should be enabled now
const updatedButton = {
...mockRightButton,
enabled: true,
};
expect(setButtons).toHaveBeenCalledWith(componentId, {
rightButtons: [updatedButton],
});
});
it('should disable save button when title is empty', () => {
const props = getBaseProps();
const {getByTestId} = renderWithIntlAndTheme(<EditChecklistItemBottomSheet {...props}/>);
const input = getByTestId('playbooks.checklist_item.edit.title_input');
// Clear title
act(() => {
fireEvent.changeText(input, '');
});
// Button should be disabled
const updatedButton = {
...mockRightButton,
enabled: false,
};
expect(setButtons).toHaveBeenCalledWith(componentId, {
rightButtons: [updatedButton],
});
});
it('should disable save button when title is only whitespace', () => {
const props = getBaseProps();
const {getByTestId} = renderWithIntlAndTheme(<EditChecklistItemBottomSheet {...props}/>);
const input = getByTestId('playbooks.checklist_item.edit.title_input');
// Set title to whitespace only
act(() => {
fireEvent.changeText(input, ' ');
});
// Button should be disabled
const updatedButton = {
...mockRightButton,
enabled: false,
};
expect(setButtons).toHaveBeenCalledWith(componentId, {
rightButtons: [updatedButton],
});
});
it('should call onSave and close when save button is pressed with valid changes', () => {
const props = getBaseProps();
const {getByTestId} = renderWithIntlAndTheme(<EditChecklistItemBottomSheet {...props}/>);
const input = getByTestId('playbooks.checklist_item.edit.title_input');
const newTitle = 'Updated Task';
// Set new title
act(() => {
fireEvent.changeText(input, newTitle);
});
// Trigger save button press
const lastCall = getLastCallForButton(jest.mocked(useNavButtonPressed), 'save-checklist-item');
const saveCallback = lastCall[2];
act(() => {
saveCallback();
});
expect(mockOnSave).toHaveBeenCalledWith({title: newTitle, description: 'Current description'});
expect(Keyboard.dismiss).toHaveBeenCalled();
expect(popTopScreen).toHaveBeenCalledWith(componentId);
});
it('should call onSave with updated description', () => {
const props = getBaseProps();
const {getByTestId} = renderWithIntlAndTheme(<EditChecklistItemBottomSheet {...props}/>);
const titleInput = getByTestId('playbooks.checklist_item.edit.title_input');
const descriptionInput = getByTestId('playbooks.checklist_item.edit.description_input');
const newTitle = 'Updated Task';
const newDescription = 'Updated description';
// Set new title and description
act(() => {
fireEvent.changeText(titleInput, newTitle);
fireEvent.changeText(descriptionInput, newDescription);
});
// Trigger save button press
const lastCall = getLastCallForButton(jest.mocked(useNavButtonPressed), 'save-checklist-item');
const saveCallback = lastCall[2];
act(() => {
saveCallback();
});
expect(mockOnSave).toHaveBeenCalledWith({title: newTitle, description: newDescription});
});
it('should trim title and description when saving', () => {
const props = getBaseProps();
const {getByTestId} = renderWithIntlAndTheme(<EditChecklistItemBottomSheet {...props}/>);
const titleInput = getByTestId('playbooks.checklist_item.edit.title_input');
const descriptionInput = getByTestId('playbooks.checklist_item.edit.description_input');
const taskTitle = ' Task with spaces ';
const taskDescription = ' Description with spaces ';
// Set title and description with spaces
act(() => {
fireEvent.changeText(titleInput, taskTitle);
fireEvent.changeText(descriptionInput, taskDescription);
});
// Trigger save button press
const lastCall = getLastCallForButton(jest.mocked(useNavButtonPressed), 'save-checklist-item');
const saveCallback = lastCall[2];
act(() => {
saveCallback();
});
expect(mockOnSave).toHaveBeenCalledWith({title: 'Task with spaces', description: 'Description with spaces'});
expect(mockOnSave).not.toHaveBeenCalledWith(taskTitle, taskDescription);
});
it('should not call onSave when save button is pressed with empty title', () => {
const props = getBaseProps();
const {getByTestId} = renderWithIntlAndTheme(<EditChecklistItemBottomSheet {...props}/>);
const input = getByTestId('playbooks.checklist_item.edit.title_input');
// Clear title
act(() => {
fireEvent.changeText(input, '');
});
// Trigger save button press
const lastCall = getLastCallForButton(jest.mocked(useNavButtonPressed), 'save-checklist-item');
const saveCallback = lastCall[2];
act(() => {
saveCallback();
});
expect(mockOnSave).not.toHaveBeenCalled();
});
it('should not call onSave when save button is pressed with whitespace-only title', () => {
const props = getBaseProps();
const {getByTestId} = renderWithIntlAndTheme(<EditChecklistItemBottomSheet {...props}/>);
const input = getByTestId('playbooks.checklist_item.edit.title_input');
// Set title to whitespace only
act(() => {
fireEvent.changeText(input, ' ');
});
// Trigger save button press
const lastCall = getLastCallForButton(jest.mocked(useNavButtonPressed), 'save-checklist-item');
const saveCallback = lastCall[2];
act(() => {
saveCallback();
});
expect(mockOnSave).not.toHaveBeenCalled();
});
it('should not enable save button when values are unchanged', () => {
const props = getBaseProps();
const {getByTestId} = renderWithIntlAndTheme(<EditChecklistItemBottomSheet {...props}/>);
const titleInput = getByTestId('playbooks.checklist_item.edit.title_input');
const descriptionInput = getByTestId('playbooks.checklist_item.edit.description_input');
// Set values back to original
act(() => {
fireEvent.changeText(titleInput, 'Current Task');
fireEvent.changeText(descriptionInput, 'Current description');
});
// Button should remain disabled
const updatedButton = {
...mockRightButton,
enabled: false,
};
expect(setButtons).toHaveBeenCalledWith(componentId, {
rightButtons: [updatedButton],
});
});
});

View file

@ -0,0 +1,126 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useState} from 'react';
import {useIntl} from 'react-intl';
import {Keyboard, StyleSheet, View} from 'react-native';
import FloatingTextInput from '@components/floating_input/floating_text_input_label';
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 type {AvailableScreens} from '@typings/screens/navigation';
type Props = {
componentId: AvailableScreens;
currentTitle: string;
currentDescription?: string;
onSave: (item: ChecklistItemInput) => void;
}
const SAVE_BUTTON_ID = 'save-checklist-item';
const close = (componentId: AvailableScreens): void => {
Keyboard.dismiss();
popTopScreen(componentId);
};
const styles = StyleSheet.create({
container: {
flex: 1,
paddingVertical: 32,
paddingHorizontal: 20,
gap: 16,
},
});
const EditChecklistItemBottomSheet = ({
componentId,
currentTitle,
currentDescription = '',
onSave,
}: Props) => {
const intl = useIntl();
const {formatMessage} = intl;
const theme = useTheme();
const [title, setTitle] = useState<string>(currentTitle);
const [description, setDescription] = useState<string>(currentDescription);
const [canSave, setCanSave] = useState(false);
const rightButton = React.useMemo(() => {
const base = buildNavigationButton(
SAVE_BUTTON_ID,
'playbooks.checklist_item.edit.button',
undefined,
formatMessage({id: 'playbooks.checklist_item.edit.button', defaultMessage: 'Save'}),
);
base.enabled = canSave;
base.color = theme.sidebarHeaderTextColor;
return base;
}, [formatMessage, canSave, theme.sidebarHeaderTextColor]);
useEffect(() => {
setButtons(componentId, {
rightButtons: [rightButton],
});
}, [rightButton, componentId]);
useEffect(() => {
const titleChanged = title.trim() !== currentTitle.trim();
const descriptionChanged = description.trim() !== currentDescription.trim();
setCanSave(title.trim().length > 0 && (titleChanged || descriptionChanged));
}, [title, description, currentTitle, currentDescription]);
const handleClose = useCallback(() => {
close(componentId);
}, [componentId]);
const handleSave = useCallback(() => {
const saveTitle = title.trim();
if (saveTitle.length > 0) {
onSave({
title: saveTitle,
description: description.trim(),
});
close(componentId);
}
}, [title, description, componentId, onSave]);
useNavButtonPressed(SAVE_BUTTON_ID, componentId, handleSave, [handleSave]);
useAndroidHardwareBackHandler(componentId, handleClose);
const titleLabel = formatMessage({id: 'playbooks.checklist_item.edit.title_label', defaultMessage: 'Task name'});
const descriptionLabel = formatMessage({id: 'playbooks.checklist_item.edit.description_label', defaultMessage: 'Description'});
return (
<View
nativeID={SecurityManager.getShieldScreenId(componentId)}
style={styles.container}
>
<FloatingTextInput
label={titleLabel}
onChangeText={setTitle}
testID='playbooks.checklist_item.edit.title_input'
value={title}
theme={theme}
autoFocus={true}
/>
<FloatingTextInput
label={descriptionLabel}
onChangeText={setDescription}
testID='playbooks.checklist_item.edit.description_input'
value={description}
theme={theme}
multiline={true}
multilineInputHeight={100}
/>
</View>
);
};
export default EditChecklistItemBottomSheet;

View file

@ -38,6 +38,11 @@ type PlaybookChecklistItem = {
update_at: number;
}
type ChecklistItemInput = {
title: string;
description?: string;
}
type PlaybookChecklist = {
id: string;
title: string;

View file

@ -1037,6 +1037,7 @@
"playbook.last_updated": "Last update {date}",
"playbook.participants": "Participants",
"playbooks.checklist_item.add.button": "New",
"playbooks.checklist_item.add.description_label": "Description",
"playbooks.checklist_item.add.label": "Task name",
"playbooks.checklist_item.add.save": "Add",
"playbooks.checklist_item.add.title": "New Task",
@ -1050,6 +1051,10 @@
"playbooks.checklist_item.delete_task_confirmation": "Are you sure you want to delete this task? This action cannot be undone.",
"playbooks.checklist_item.delete_task_title": "Delete task",
"playbooks.checklist_item.due_date": "Due date",
"playbooks.checklist_item.edit.button": "Save",
"playbooks.checklist_item.edit.description_label": "Description",
"playbooks.checklist_item.edit.title": "Edit Task",
"playbooks.checklist_item.edit.title_label": "Task name",
"playbooks.checklist_item.none": "None",
"playbooks.checklist_item.rerun_command": "Rerun command",
"playbooks.checklist_item.run_command": "Run command",