From 90135d8580f7278e0b6f17a61c0e79c33e457716 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guillermo=20Vay=C3=A1?= Date: Thu, 15 Jan 2026 15:49:13 +0100 Subject: [PATCH] [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 --- .../playbooks/actions/local/checklist.test.ts | 78 +++- .../playbooks/actions/local/checklist.ts | 27 ++ .../actions/remote/checklist.test.ts | 62 ++- .../playbooks/actions/remote/checklist.ts | 26 +- app/products/playbooks/client/rest.test.ts | 90 ++++- app/products/playbooks/client/rest.ts | 14 +- app/products/playbooks/constants/screens.ts | 2 + app/products/playbooks/screens/index.test.tsx | 7 + app/products/playbooks/screens/index.tsx | 2 + .../playbooks/screens/navigation.test.ts | 63 ++- app/products/playbooks/screens/navigation.ts | 19 +- .../add_checklist_item_bottom_sheet.test.tsx | 51 ++- .../add_checklist_item_bottom_sheet.tsx | 25 +- .../playbook_run/checklist/checklist.test.tsx | 8 +- .../playbook_run/checklist/checklist.tsx | 4 +- .../checklist_item_bottom_sheet.test.tsx | 110 ++++- .../checklist_item_bottom_sheet.tsx | 58 ++- .../edit_checklist_item_bottom_sheet.test.tsx | 379 ++++++++++++++++++ .../edit_checklist_item_bottom_sheet.tsx | 126 ++++++ app/products/playbooks/types/api.d.ts | 5 + assets/base/i18n/en.json | 5 + 21 files changed, 1110 insertions(+), 51 deletions(-) create mode 100644 app/products/playbooks/screens/playbook_run/checklist/edit_checklist_item_bottom_sheet.test.tsx create mode 100644 app/products/playbooks/screens/playbook_run/checklist/edit_checklist_item_bottom_sheet.tsx diff --git a/app/products/playbooks/actions/local/checklist.test.ts b/app/products/playbooks/actions/local/checklist.test.ts index a398dd72d..c1f507029 100644 --- a/app/products/playbooks/actions/local/checklist.test.ts +++ b/app/products/playbooks/actions/local/checklist.test.ts @@ -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(''); + }); +}); diff --git a/app/products/playbooks/actions/local/checklist.ts b/app/products/playbooks/actions/local/checklist.ts index 0d697fba7..709530b42 100644 --- a/app/products/playbooks/actions/local/checklist.ts +++ b/app/products/playbooks/actions/local/checklist.ts @@ -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}; + } +} diff --git a/app/products/playbooks/actions/remote/checklist.test.ts b/app/products/playbooks/actions/remote/checklist.test.ts index 432ca0939..389f6200e 100644 --- a/app/products/playbooks/actions/remote/checklist.test.ts +++ b/app/products/playbooks/actions/remote/checklist.test.ts @@ -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, ''); + }); + }); }); diff --git a/app/products/playbooks/actions/remote/checklist.ts b/app/products/playbooks/actions/remote/checklist.ts index a61177fac..0a29d156e 100644 --- a/app/products/playbooks/actions/remote/checklist.ts +++ b/app/products/playbooks/actions/remote/checklist.ts @@ -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}; + } +}; diff --git a/app/products/playbooks/client/rest.test.ts b/app/products/playbooks/client/rest.test.ts index 67e04306b..ff3d47f4d 100644 --- a/app/products/playbooks/client/rest.test.ts +++ b/app/products/playbooks/client/rest.test.ts @@ -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'; diff --git a/app/products/playbooks/client/rest.ts b/app/products/playbooks/client/rest.ts index 64f7f7442..a8b6cd9b1 100644 --- a/app/products/playbooks/client/rest.ts +++ b/app/products/playbooks/client/rest.ts @@ -28,8 +28,9 @@ export interface ClientPlaybooksMix { restoreChecklistItem: (playbookRunID: string, checklistNum: number, itemNum: number) => Promise; setAssignee: (playbookRunId: string, checklistNum: number, itemNum: number, assigneeId?: string) => Promise; setDueDate: (playbookRunId: string, checklistNum: number, itemNum: number, date?: number) => Promise; - addChecklistItem: (playbookRunId: string, checklistNum: number, title: string) => Promise; + addChecklistItem: (playbookRunId: string, checklistNum: number, item: ChecklistItemInput) => Promise; deleteChecklistItem: (playbookRunId: string, checklistNum: number, itemNum: number) => Promise; + updateChecklistItem: (playbookRunId: string, checklistNum: number, itemNum: number, item: ChecklistItemInput) => Promise; renameChecklist: (playbookRunId: string, checklistNumber: number, newName: string) => Promise; @@ -210,10 +211,17 @@ const ClientPlaybooks = >(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}, ); }; diff --git a/app/products/playbooks/constants/screens.ts b/app/products/playbooks/constants/screens.ts index b33567a68..aca59832d 100644 --- a/app/products/playbooks/constants/screens.ts +++ b/app/products/playbooks/constants/screens.ts @@ -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, diff --git a/app/products/playbooks/screens/index.test.tsx b/app/products/playbooks/screens/index.test.tsx index 1a76d331d..31ac766f0 100644 --- a/app/products/playbooks/screens/index.test.tsx +++ b/app/products/playbooks/screens/index.test.tsx @@ -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) => {Screens.PLAYBOOK_ADD_CHECKLIST_ITEM}); +jest.mock('@playbooks/screens/playbook_run/checklist/edit_checklist_item_bottom_sheet', () => ({ + __esModule: true, + default: jest.fn(), +})); +jest.mocked(EditChecklistItemBottomSheet).mockImplementation((props) => {Screens.PLAYBOOK_EDIT_CHECKLIST_ITEM}); + jest.mock('@playbooks/screens/playbook_run/rename_playbook_run_bottom_sheet', () => ({ __esModule: true, default: jest.fn(), diff --git a/app/products/playbooks/screens/index.tsx b/app/products/playbooks/screens/index.tsx index aeb24e164..1c0068d27 100644 --- a/app/products/playbooks/screens/index.tsx +++ b/app/products/playbooks/screens/index.tsx @@ -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: diff --git a/app/products/playbooks/screens/navigation.test.ts b/app/products/playbooks/screens/navigation.test.ts index 97bfb764e..57c57b2d1 100644 --- a/app/products/playbooks/screens/navigation.test.ts +++ b/app/products/playbooks/screens/navigation.test.ts @@ -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'; diff --git a/app/products/playbooks/screens/navigation.ts b/app/products/playbooks/screens/navigation.ts index 983e0a1b8..daed0066f 100644 --- a/app/products/playbooks/screens/navigation.ts +++ b/app/products/playbooks/screens/navigation.ts @@ -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, diff --git a/app/products/playbooks/screens/playbook_run/checklist/add_checklist_item_bottom_sheet.test.tsx b/app/products/playbooks/screens/playbook_run/checklist/add_checklist_item_bottom_sheet.test.tsx index 4794ebf41..d7e7adc2b 100644 --- a/app/products/playbooks/screens/playbook_run/checklist/add_checklist_item_bottom_sheet.test.tsx +++ b/app/products/playbooks/screens/playbook_run/checklist/add_checklist_item_bottom_sheet.test.tsx @@ -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(); + + 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(); + + 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(); @@ -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); }); diff --git a/app/products/playbooks/screens/playbook_run/checklist/add_checklist_item_bottom_sheet.tsx b/app/products/playbooks/screens/playbook_run/checklist/add_checklist_item_bottom_sheet.tsx index 53e17e533..0bbc05709 100644 --- a/app/products/playbooks/screens/playbook_run/checklist/add_checklist_item_bottom_sheet.tsx +++ b/app/products/playbooks/screens/playbook_run/checklist/add_checklist_item_bottom_sheet.tsx @@ -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(''); + const [description, setDescription] = useState(''); 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 ( + ); }; diff --git a/app/products/playbooks/screens/playbook_run/checklist/checklist.test.tsx b/app/products/playbooks/screens/playbook_run/checklist/checklist.test.tsx index 5a5c82aad..a3cdd366c 100644 --- a/app/products/playbooks/screens/playbook_run/checklist/checklist.test.tsx +++ b/app/products/playbooks/screens/playbook_run/checklist/checklist.test.tsx @@ -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(); diff --git a/app/products/playbooks/screens/playbook_run/checklist/checklist.tsx b/app/products/playbooks/screens/playbook_run/checklist/checklist.tsx index 331c45347..d518cdbbd 100644 --- a/app/products/playbooks/screens/playbook_run/checklist/checklist.tsx +++ b/app/products/playbooks/screens/playbook_run/checklist/checklist.tsx @@ -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)); diff --git a/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet/checklist_item_bottom_sheet.test.tsx b/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet/checklist_item_bottom_sheet.test.tsx index 94051c613..87ff82413 100644 --- a/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet/checklist_item_bottom_sheet.test.tsx +++ b/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet/checklist_item_bottom_sheet.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + }); + }); + }); }); diff --git a/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet/checklist_item_bottom_sheet.tsx b/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet/checklist_item_bottom_sheet.tsx index 795f2de53..841e5322d 100644 --- a/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet/checklist_item_bottom_sheet.tsx +++ b/app/products/playbooks/screens/playbook_run/checklist/checklist_item/checklist_item_bottom_sheet/checklist_item_bottom_sheet.tsx @@ -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 = () => ( - - - {item.title} - - {Boolean(item.description) && ( - - {item.description} + + + + {item.title} + {(item.description) && ( + + {item.description} + + )} + + {!isDisabled && ( + pressed && styles.editIconPressed} + testID='checklist_item.edit_button' + > + + )} diff --git a/app/products/playbooks/screens/playbook_run/checklist/edit_checklist_item_bottom_sheet.test.tsx b/app/products/playbooks/screens/playbook_run/checklist/edit_checklist_item_bottom_sheet.test.tsx new file mode 100644 index 000000000..7c8d714e5 --- /dev/null +++ b/app/products/playbooks/screens/playbook_run/checklist/edit_checklist_item_bottom_sheet.test.tsx @@ -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(); + + 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(); + + 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(); + + 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(); + + expect(useNavButtonPressed).toHaveBeenCalledWith( + 'save-checklist-item', + componentId, + expect.any(Function), + [expect.any(Function)], + ); + }); + + it('should register Android back handler', () => { + const props = getBaseProps(); + renderWithIntlAndTheme(); + + expect(useAndroidHardwareBackHandler).toHaveBeenCalledWith( + componentId, + expect.any(Function), + ); + }); + + it('should update title when text input changes', () => { + const props = getBaseProps(); + const {getByTestId} = renderWithIntlAndTheme(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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], + }); + }); +}); + diff --git a/app/products/playbooks/screens/playbook_run/checklist/edit_checklist_item_bottom_sheet.tsx b/app/products/playbooks/screens/playbook_run/checklist/edit_checklist_item_bottom_sheet.tsx new file mode 100644 index 000000000..7c059cdc6 --- /dev/null +++ b/app/products/playbooks/screens/playbook_run/checklist/edit_checklist_item_bottom_sheet.tsx @@ -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(currentTitle); + const [description, setDescription] = useState(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 ( + + + + + ); +}; + +export default EditChecklistItemBottomSheet; + diff --git a/app/products/playbooks/types/api.d.ts b/app/products/playbooks/types/api.d.ts index cbe84de5a..428cac65b 100644 --- a/app/products/playbooks/types/api.d.ts +++ b/app/products/playbooks/types/api.d.ts @@ -38,6 +38,11 @@ type PlaybookChecklistItem = { update_at: number; } +type ChecklistItemInput = { + title: string; + description?: string; +} + type PlaybookChecklist = { id: string; title: string; diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 7b3b01ea0..480dbc7b0 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -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",