diff --git a/app/components/button/index.tsx b/app/components/button/index.tsx index b90aa38ff..cafb56452 100644 --- a/app/components/button/index.tsx +++ b/app/components/button/index.tsx @@ -95,6 +95,7 @@ const Button = ({ const loadingComponent = ( ); diff --git a/app/products/playbooks/actions/local/run.test.ts b/app/products/playbooks/actions/local/run.test.ts index fd9f8a209..5647c2737 100644 --- a/app/products/playbooks/actions/local/run.test.ts +++ b/app/products/playbooks/actions/local/run.test.ts @@ -22,7 +22,7 @@ afterEach(async () => { describe('handlePlaybookRuns', () => { it('should handle not found database', async () => { - const {error} = await handlePlaybookRuns('foo', [], false, false); + const {error} = await handlePlaybookRuns('foo', []); expect(error).toBeDefined(); expect((error as Error).message).toContain('foo database not found'); }); diff --git a/app/products/playbooks/actions/remote/playbooks.test.ts b/app/products/playbooks/actions/remote/playbooks.test.ts new file mode 100644 index 000000000..dfcc1dde7 --- /dev/null +++ b/app/products/playbooks/actions/remote/playbooks.test.ts @@ -0,0 +1,112 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {forceLogoutIfNecessary} from '@actions/remote/session'; +import NetworkManager from '@managers/network_manager'; +import TestHelper from '@test/test_helper'; +import {logDebug} from '@utils/log'; + +import {fetchPlaybooks} from './playbooks'; + +jest.mock('@actions/remote/session', () => ({ + forceLogoutIfNecessary: jest.fn(), +})); + +const serverUrl = 'baseHandler.test.com'; + +const mockPlaybooksResponse: FetchPlaybooksReturn = { + total_count: 2, + page_count: 1, + has_more: false, + items: [ + TestHelper.fakePlaybook({ + id: 'playbook-id-1', + title: 'Test Playbook 1', + description: 'Test Description 1', + }), + TestHelper.fakePlaybook({ + id: 'playbook-id-2', + title: 'Test Playbook 2', + description: 'Test Description 2', + }), + ], +}; + +const mockClient = { + fetchPlaybooks: jest.fn(), +}; + +const throwFunc = () => { + throw Error('error'); +}; + +beforeAll(() => { + // @ts-ignore + NetworkManager.getClient = () => mockClient; +}); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('fetchPlaybooks', () => { + it('should fetch playbooks successfully', async () => { + mockClient.fetchPlaybooks.mockResolvedValueOnce(mockPlaybooksResponse); + + const params: FetchPlaybooksParams = { + team_id: 'team-id-1', + page: 0, + per_page: 10, + }; + + const result = await fetchPlaybooks(serverUrl, params); + + expect(result).toBeDefined(); + expect(result.error).toBeUndefined(); + expect(result.data).toEqual(mockPlaybooksResponse); + expect(mockClient.fetchPlaybooks).toHaveBeenCalledWith(params); + expect(logDebug).not.toHaveBeenCalled(); + expect(forceLogoutIfNecessary).not.toHaveBeenCalled(); + }); + + it('should handle client error', async () => { + jest.spyOn(NetworkManager, 'getClient').mockImplementationOnce(throwFunc); + + const params: FetchPlaybooksParams = { + team_id: 'team-id-1', + }; + + const result = await fetchPlaybooks(serverUrl, params); + + expect(result).toBeDefined(); + expect(result.error).toBeDefined(); + expect(result.data).toBeUndefined(); + expect(mockClient.fetchPlaybooks).not.toHaveBeenCalled(); + expect(logDebug).toHaveBeenCalled(); + expect(forceLogoutIfNecessary).toHaveBeenCalledWith(serverUrl, expect.any(Error)); + }); + + it('should handle API exception', async () => { + const apiError = new Error('API error'); + mockClient.fetchPlaybooks.mockRejectedValueOnce(apiError); + + const params: FetchPlaybooksParams = { + team_id: 'team-id-1', + page: 1, + per_page: 20, + sort: 'title', + direction: 'asc', + }; + + const result = await fetchPlaybooks(serverUrl, params); + + expect(result).toBeDefined(); + expect(result.error).toBeDefined(); + expect(result.error).toBe(apiError); + expect(result.data).toBeUndefined(); + expect(mockClient.fetchPlaybooks).toHaveBeenCalledWith(params); + expect(logDebug).toHaveBeenCalledWith('error on fetchPlaybooks', expect.any(String)); + expect(forceLogoutIfNecessary).toHaveBeenCalledWith(serverUrl, apiError); + }); +}); + diff --git a/app/products/playbooks/actions/remote/runs.test.ts b/app/products/playbooks/actions/remote/runs.test.ts index d779fd4e2..bb0d7446a 100644 --- a/app/products/playbooks/actions/remote/runs.test.ts +++ b/app/products/playbooks/actions/remote/runs.test.ts @@ -1,6 +1,8 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +/* eslint-disable max-lines */ + import {PER_PAGE_DEFAULT} from '@client/rest/constants'; import DatabaseManager from '@database/manager'; import NetworkManager from '@managers/network_manager'; @@ -10,7 +12,7 @@ import {getLastPlaybookRunsFetchAt} from '@playbooks/database/queries/run'; import EphemeralStore from '@store/ephemeral_store'; import TestHelper from '@test/test_helper'; -import {fetchPlaybookRunsForChannel, fetchFinishedRunsForChannel, fetchPlaybookRunsPageForParticipant, setOwner, finishRun} from './runs'; +import {fetchPlaybookRunsForChannel, fetchFinishedRunsForChannel, fetchPlaybookRunsPageForParticipant, setOwner, finishRun, createPlaybookRun, fetchPlaybookRun, fetchPlaybookRunMetadata, postStatusUpdate} from './runs'; const serverUrl = 'baseHandler.test.com'; const channelId = 'channel-id-1'; @@ -20,8 +22,12 @@ const mockPlaybookRun2 = TestHelper.fakePlaybookRun({channel_id: channelId}); const mockClient = { fetchPlaybookRuns: jest.fn(), + fetchPlaybookRun: jest.fn(), + fetchPlaybookRunMetadata: jest.fn(), setOwner: jest.fn(), finishRun: jest.fn(), + createPlaybookRun: jest.fn(), + postStatusUpdate: jest.fn(), }; jest.mock('@playbooks/database/queries/run'); @@ -48,6 +54,10 @@ afterEach(async () => { describe('fetchPlaybookRunsForChannel', () => { jest.mocked(getLastPlaybookRunsFetchAt).mockResolvedValue(123); + beforeEach(() => { + jest.mocked(handlePlaybookRuns).mockResolvedValue({data: []}); + }); + it('should handle client error', async () => { jest.spyOn(NetworkManager, 'getClient').mockImplementationOnce(throwFunc); @@ -161,6 +171,19 @@ describe('fetchPlaybookRunsForChannel', () => { await fetchPlaybookRunsForChannel(serverUrl, channelId, true); expect(EphemeralStore.getChannelPlaybooksSynced(serverUrl, channelId)).toBe(false); }); + + it('should handle error from handlePlaybookRuns', async () => { + jest.mocked(handlePlaybookRuns).mockResolvedValueOnce({error: new Error('Handle error')}); + mockClient.fetchPlaybookRuns.mockResolvedValueOnce({ + items: [mockPlaybookRun], + has_more: false, + }); + + const result = await fetchPlaybookRunsForChannel(serverUrl, channelId); + expect(handlePlaybookRuns).toHaveBeenCalled(); + expect(result).toBeDefined(); + expect((result.error as Error).message).toBe('Handle error'); + }); }); describe('fetchFinishedRunsForChannel', () => { @@ -415,3 +438,284 @@ describe('fetchPlaybookRunsPageForParticipant', () => { }); }); }); + +describe('createPlaybookRun', () => { + const playbookId = 'playbook-id-1'; + const ownerUserId = 'owner-user-id-1'; + const teamId = 'team-id-1'; + const name = 'Test Playbook Run'; + const description = 'Test Description'; + const createPublicRun = true; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should create playbook run successfully with all required parameters', async () => { + const mockRun = TestHelper.fakePlaybookRun({ + id: 'run-id-1', + playbook_id: playbookId, + owner_user_id: ownerUserId, + team_id: teamId, + name, + description, + }); + mockClient.createPlaybookRun.mockResolvedValueOnce(mockRun); + + const result = await createPlaybookRun(serverUrl, playbookId, ownerUserId, teamId, name, description); + + expect(result).toBeDefined(); + expect(result.error).toBeUndefined(); + expect(result.data).toEqual(mockRun); + expect(mockClient.createPlaybookRun).toHaveBeenCalledWith( + playbookId, + ownerUserId, + teamId, + name, + description, + undefined, + undefined, + ); + }); + + it('should create playbook run successfully with optional parameters', async () => { + const mockRun = TestHelper.fakePlaybookRun({ + id: 'run-id-1', + playbook_id: playbookId, + owner_user_id: ownerUserId, + team_id: teamId, + name, + description, + channel_id: channelId, + }); + mockClient.createPlaybookRun.mockResolvedValueOnce(mockRun); + + const result = await createPlaybookRun(serverUrl, playbookId, ownerUserId, teamId, name, description, channelId, createPublicRun); + + expect(result).toBeDefined(); + expect(result.error).toBeUndefined(); + expect(result.data).toEqual(mockRun); + expect(mockClient.createPlaybookRun).toHaveBeenCalledWith( + playbookId, + ownerUserId, + teamId, + name, + description, + channelId, + createPublicRun, + ); + }); + + it('should handle client error', async () => { + const clientError = new Error('Client error'); + mockClient.createPlaybookRun.mockRejectedValueOnce(clientError); + + const result = await createPlaybookRun(serverUrl, playbookId, ownerUserId, teamId, name, description); + + expect(result).toBeDefined(); + expect(result.error).toBeDefined(); + expect(result.data).toBeUndefined(); + expect(mockClient.createPlaybookRun).toHaveBeenCalledWith( + playbookId, + ownerUserId, + teamId, + name, + description, + undefined, + undefined, + ); + }); + + it('should handle network manager error', async () => { + jest.spyOn(NetworkManager, 'getClient').mockImplementationOnce(throwFunc); + + const result = await createPlaybookRun(serverUrl, playbookId, ownerUserId, teamId, name, description); + + expect(result).toBeDefined(); + expect(result.error).toBeDefined(); + expect(result.data).toBeUndefined(); + }); +}); + +describe('fetchPlaybookRun', () => { + const runId = 'run-id-1'; + + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(handlePlaybookRuns).mockResolvedValue({data: []}); + }); + + it('should fetch playbook run successfully', async () => { + const mockRun = TestHelper.fakePlaybookRun({ + id: runId, + channel_id: channelId, + }); + mockClient.fetchPlaybookRun.mockResolvedValueOnce(mockRun); + + const result = await fetchPlaybookRun(serverUrl, runId); + + expect(result).toBeDefined(); + expect(result.error).toBeUndefined(); + expect(result.run).toEqual(mockRun); + expect(mockClient.fetchPlaybookRun).toHaveBeenCalledWith(runId); + expect(handlePlaybookRuns).toHaveBeenCalledWith(serverUrl, [mockRun], false, true); + }); + + it('should fetch playbook run successfully with fetchOnly mode', async () => { + const mockRun = TestHelper.fakePlaybookRun({ + id: runId, + channel_id: channelId, + }); + mockClient.fetchPlaybookRun.mockResolvedValueOnce(mockRun); + + const result = await fetchPlaybookRun(serverUrl, runId, true); + + expect(result).toBeDefined(); + expect(result.error).toBeUndefined(); + expect(result.run).toEqual(mockRun); + expect(mockClient.fetchPlaybookRun).toHaveBeenCalledWith(runId); + expect(handlePlaybookRuns).not.toHaveBeenCalled(); + }); + + it('should handle client error', async () => { + const clientError = new Error('Client error'); + mockClient.fetchPlaybookRun.mockRejectedValueOnce(clientError); + + const result = await fetchPlaybookRun(serverUrl, runId); + + expect(result).toBeDefined(); + expect(result.error).toBeDefined(); + expect(result.run).toBeUndefined(); + expect(mockClient.fetchPlaybookRun).toHaveBeenCalledWith(runId); + expect(handlePlaybookRuns).not.toHaveBeenCalled(); + }); + + it('should handle handlePlaybookRuns error', async () => { + const mockRun = TestHelper.fakePlaybookRun({ + id: runId, + channel_id: channelId, + }); + const handleError = new Error('Handle error'); + mockClient.fetchPlaybookRun.mockResolvedValueOnce(mockRun); + jest.mocked(handlePlaybookRuns).mockResolvedValueOnce({error: handleError}); + + const result = await fetchPlaybookRun(serverUrl, runId); + + expect(result).toBeDefined(); + expect(result.error).toEqual(handleError); + expect(result.run).toBeUndefined(); + expect(mockClient.fetchPlaybookRun).toHaveBeenCalledWith(runId); + expect(handlePlaybookRuns).toHaveBeenCalledWith(serverUrl, [mockRun], false, true); + }); + + it('should handle network manager error', async () => { + jest.spyOn(NetworkManager, 'getClient').mockImplementationOnce(throwFunc); + + const result = await fetchPlaybookRun(serverUrl, runId); + + expect(result).toBeDefined(); + expect(result.error).toBeDefined(); + expect(result.run).toBeUndefined(); + expect(handlePlaybookRuns).not.toHaveBeenCalled(); + }); +}); + +describe('fetchPlaybookRunMetadata', () => { + const runId = 'run-id-1'; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should fetch playbook run metadata successfully', async () => { + const mockMetadata = TestHelper.fakePlaybookRunMetadata(); + mockClient.fetchPlaybookRunMetadata.mockResolvedValueOnce(mockMetadata); + + const result = await fetchPlaybookRunMetadata(serverUrl, runId); + + expect(result).toBeDefined(); + expect(result.error).toBeUndefined(); + expect(result.metadata).toEqual(mockMetadata); + expect(mockClient.fetchPlaybookRunMetadata).toHaveBeenCalledWith(runId); + }); + + it('should handle client error', async () => { + const clientError = new Error('Client error'); + mockClient.fetchPlaybookRunMetadata.mockRejectedValueOnce(clientError); + + const result = await fetchPlaybookRunMetadata(serverUrl, runId); + + expect(result).toBeDefined(); + expect(result.error).toBeDefined(); + expect(result.metadata).toBeUndefined(); + expect(mockClient.fetchPlaybookRunMetadata).toHaveBeenCalledWith(runId); + }); + + it('should handle network manager error', async () => { + jest.spyOn(NetworkManager, 'getClient').mockImplementationOnce(throwFunc); + + const result = await fetchPlaybookRunMetadata(serverUrl, runId); + + expect(result).toBeDefined(); + expect(result.error).toBeDefined(); + expect(result.metadata).toBeUndefined(); + }); +}); + +describe('postStatusUpdate', () => { + const playbookRunID = 'run-id-1'; + const payload = { + message: 'Test status update message', + reminder: 3600000, + finishRun: false, + }; + const ids = { + user_id: 'user-id-1', + channel_id: 'channel-id-1', + team_id: 'team-id-1', + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should post status update successfully', async () => { + mockClient.postStatusUpdate.mockResolvedValueOnce(undefined); + + const result = await postStatusUpdate(serverUrl, playbookRunID, payload, ids); + expect(result.error).toBeUndefined(); + + expect(mockClient.postStatusUpdate).toHaveBeenCalledWith(playbookRunID, payload, ids); + expect(mockClient.postStatusUpdate).toHaveBeenCalledTimes(1); + }); + + it('should post status update successfully without reminder', async () => { + const payloadWithoutReminder = { + message: 'Test status update message', + finishRun: false, + }; + mockClient.postStatusUpdate.mockResolvedValueOnce(undefined); + + const result = await postStatusUpdate(serverUrl, playbookRunID, payloadWithoutReminder, ids); + expect(result.error).toBeUndefined(); + + expect(mockClient.postStatusUpdate).toHaveBeenCalledWith(playbookRunID, payloadWithoutReminder, ids); + }); + + it('should handle client error gracefully', async () => { + const clientError = new Error('Client error'); + mockClient.postStatusUpdate.mockRejectedValueOnce(clientError); + + const result = await postStatusUpdate(serverUrl, playbookRunID, payload, ids); + expect(result.error).toBe(clientError); + + expect(mockClient.postStatusUpdate).toHaveBeenCalledWith(playbookRunID, payload, ids); + }); + + it('should handle network manager error gracefully', async () => { + jest.spyOn(NetworkManager, 'getClient').mockImplementationOnce(throwFunc); + + const result = await postStatusUpdate(serverUrl, playbookRunID, payload, ids); + expect(result.error).toBeDefined(); + }); +}); diff --git a/app/products/playbooks/actions/remote/runs.ts b/app/products/playbooks/actions/remote/runs.ts index 23d9423d8..891c65cd6 100644 --- a/app/products/playbooks/actions/remote/runs.ts +++ b/app/products/playbooks/actions/remote/runs.ts @@ -51,7 +51,7 @@ export const fetchPlaybookRunsForChannel = async (serverUrl: string, channelId: if (!fetchOnly) { const result = await handlePlaybookRuns(serverUrl, allRuns, false, true); - if (result && result.error) { + if (result.error) { throw result.error; } @@ -161,9 +161,11 @@ export const postStatusUpdate = async (serverUrl: string, playbookRunID: string, try { const client = NetworkManager.getClient(serverUrl); await client.postStatusUpdate(playbookRunID, payload, ids); + return {data: true}; } catch (error) { logDebug('error on postStatusUpdate', getFullErrorMessage(error)); forceLogoutIfNecessary(serverUrl, error); + return {error}; } }; diff --git a/app/products/playbooks/actions/websocket/events.test.ts b/app/products/playbooks/actions/websocket/events.test.ts new file mode 100644 index 000000000..93aa78289 --- /dev/null +++ b/app/products/playbooks/actions/websocket/events.test.ts @@ -0,0 +1,151 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {WebsocketEvents} from '@constants'; +import {PLAYBOOKS_PLUGIN_ID} from '@playbooks/constants/plugin'; +import {WEBSOCKET_EVENTS} from '@playbooks/constants/websocket'; +import TestHelper from '@test/test_helper'; + +import {handlePlaybookEvents} from './events'; +import { + handlePlaybookRunCreated, + handlePlaybookRunUpdated, + handlePlaybookRunUpdatedIncremental, +} from './runs'; +import {handlePlaybookPluginDisabled, handlePlaybookPluginEnabled} from './version'; + +const serverUrl = 'test-server.com'; + +jest.mock('./runs'); +jest.mock('./version'); + +describe('handlePlaybookEvents', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should handle PLUGIN_ENABLED event', async () => { + const manifest: ClientPluginManifest = { + id: PLAYBOOKS_PLUGIN_ID, + version: '2.0.0', + webapp: { + bundle_path: '/static/playbooks.js', + }, + }; + + const msg = TestHelper.fakeWebsocketMessage({ + event: WebsocketEvents.PLUGIN_ENABLED, + data: {manifest}, + }); + + await handlePlaybookEvents(serverUrl, msg); + + expect(handlePlaybookPluginEnabled).toHaveBeenCalledWith(serverUrl, manifest); + expect(handlePlaybookPluginEnabled).toHaveBeenCalledTimes(1); + expect(handlePlaybookPluginDisabled).not.toHaveBeenCalled(); + expect(handlePlaybookRunCreated).not.toHaveBeenCalled(); + expect(handlePlaybookRunUpdated).not.toHaveBeenCalled(); + expect(handlePlaybookRunUpdatedIncremental).not.toHaveBeenCalled(); + }); + + it('should handle PLUGIN_DISABLED event', async () => { + const manifest: ClientPluginManifest = { + id: PLAYBOOKS_PLUGIN_ID, + version: '2.0.0', + webapp: { + bundle_path: '/static/playbooks.js', + }, + }; + + const msg = TestHelper.fakeWebsocketMessage({ + event: WebsocketEvents.PLUGIN_DISABLED, + data: {manifest}, + }); + + await handlePlaybookEvents(serverUrl, msg); + + expect(handlePlaybookPluginDisabled).toHaveBeenCalledWith(serverUrl, manifest); + expect(handlePlaybookPluginDisabled).toHaveBeenCalledTimes(1); + expect(handlePlaybookPluginEnabled).not.toHaveBeenCalled(); + expect(handlePlaybookRunCreated).not.toHaveBeenCalled(); + expect(handlePlaybookRunUpdated).not.toHaveBeenCalled(); + expect(handlePlaybookRunUpdatedIncremental).not.toHaveBeenCalled(); + }); + + it('should handle WEBSOCKET_PLAYBOOK_RUN_CREATED event', async () => { + const msg = TestHelper.fakeWebsocketMessage({ + event: WEBSOCKET_EVENTS.WEBSOCKET_PLAYBOOK_RUN_CREATED, + data: { + payload: JSON.stringify({ + playbook_run: {id: 'run1'}, + }), + }, + }); + + await handlePlaybookEvents(serverUrl, msg); + + expect(handlePlaybookRunCreated).toHaveBeenCalledWith(serverUrl, msg); + expect(handlePlaybookRunCreated).toHaveBeenCalledTimes(1); + expect(handlePlaybookRunUpdated).not.toHaveBeenCalled(); + expect(handlePlaybookRunUpdatedIncremental).not.toHaveBeenCalled(); + expect(handlePlaybookPluginEnabled).not.toHaveBeenCalled(); + expect(handlePlaybookPluginDisabled).not.toHaveBeenCalled(); + }); + + it('should handle WEBSOCKET_PLAYBOOK_RUN_UPDATED event', async () => { + const msg = TestHelper.fakeWebsocketMessage({ + event: WEBSOCKET_EVENTS.WEBSOCKET_PLAYBOOK_RUN_UPDATED, + data: { + payload: JSON.stringify({ + id: 'run1', + }), + }, + }); + + await handlePlaybookEvents(serverUrl, msg); + + expect(handlePlaybookRunUpdated).toHaveBeenCalledWith(serverUrl, msg); + expect(handlePlaybookRunUpdated).toHaveBeenCalledTimes(1); + expect(handlePlaybookRunCreated).not.toHaveBeenCalled(); + expect(handlePlaybookRunUpdatedIncremental).not.toHaveBeenCalled(); + expect(handlePlaybookPluginEnabled).not.toHaveBeenCalled(); + expect(handlePlaybookPluginDisabled).not.toHaveBeenCalled(); + }); + + it('should handle WEBSOCKET_PLAYBOOK_RUN_UPDATED_INCREMENTAL event', async () => { + const msg = TestHelper.fakeWebsocketMessage({ + event: WEBSOCKET_EVENTS.WEBSOCKET_PLAYBOOK_RUN_UPDATED_INCREMENTAL, + data: { + payload: JSON.stringify({ + id: 'run1', + changed_fields: {}, + }), + }, + }); + + await handlePlaybookEvents(serverUrl, msg); + + expect(handlePlaybookRunUpdatedIncremental).toHaveBeenCalledWith(serverUrl, msg); + expect(handlePlaybookRunUpdatedIncremental).toHaveBeenCalledTimes(1); + expect(handlePlaybookRunCreated).not.toHaveBeenCalled(); + expect(handlePlaybookRunUpdated).not.toHaveBeenCalled(); + expect(handlePlaybookPluginEnabled).not.toHaveBeenCalled(); + expect(handlePlaybookPluginDisabled).not.toHaveBeenCalled(); + }); + + it('should handle unknown event by doing nothing', async () => { + const msg = TestHelper.fakeWebsocketMessage({ + event: 'unknown_event', + data: {}, + }); + + await handlePlaybookEvents(serverUrl, msg); + + expect(handlePlaybookRunCreated).not.toHaveBeenCalled(); + expect(handlePlaybookRunUpdated).not.toHaveBeenCalled(); + expect(handlePlaybookRunUpdatedIncremental).not.toHaveBeenCalled(); + expect(handlePlaybookPluginEnabled).not.toHaveBeenCalled(); + expect(handlePlaybookPluginDisabled).not.toHaveBeenCalled(); + }); +}); + diff --git a/app/products/playbooks/actions/websocket/reconnect.test.ts b/app/products/playbooks/actions/websocket/reconnect.test.ts index 39fad9270..7886fe526 100644 --- a/app/products/playbooks/actions/websocket/reconnect.test.ts +++ b/app/products/playbooks/actions/websocket/reconnect.test.ts @@ -10,6 +10,7 @@ import {getCurrentChannelId} from '@queries/servers/system'; import EphemeralStore from '@store/ephemeral_store'; import NavigationStore from '@store/navigation_store'; import {isTablet} from '@utils/helpers'; +import {logDebug} from '@utils/log'; import {handlePlaybookReconnect} from './reconnect'; @@ -104,6 +105,32 @@ describe('handlePlaybookReconnect', () => { expect(fetchPlaybookRunsForChannel).toHaveBeenCalledWith(serverUrl, mockCurrentChannelId); expect(fetchPlaybookRunsForChannel).toHaveBeenCalledTimes(1); }); + + it('should handle error from fetchPlaybookRunsForChannel', async () => { + jest.spyOn(NavigationStore, 'getScreensInStack').mockReturnValue([Screens.CHANNEL]); + jest.mocked(fetchIsPlaybooksEnabled).mockResolvedValue(true); + const error = new Error('Fetch error'); + jest.mocked(fetchPlaybookRunsForChannel).mockResolvedValueOnce({error}); + + await handlePlaybookReconnect(serverUrl); + + expect(fetchPlaybookRunsForChannel).toHaveBeenCalledWith(serverUrl, mockCurrentChannelId); + expect(fetchPlaybookRunsForChannel).toHaveBeenCalledTimes(1); + expect(logDebug).toHaveBeenCalledWith('Error fetching playbook runs on reconnect', error); + }); + + it('should handle error from updatePlaybooksVersion', async () => { + jest.spyOn(NavigationStore, 'getScreensInStack').mockReturnValue([Screens.CHANNEL]); + jest.mocked(fetchIsPlaybooksEnabled).mockResolvedValue(true); + const error = new Error('Update error'); + jest.mocked(updatePlaybooksVersion).mockResolvedValueOnce({error}); + + await handlePlaybookReconnect(serverUrl); + + expect(updatePlaybooksVersion).toHaveBeenCalledWith(serverUrl); + expect(updatePlaybooksVersion).toHaveBeenCalledTimes(1); + expect(logDebug).toHaveBeenCalledWith('Error updating playbooks version on reconnect', error); + }); }); describe('on tablet device', () => { diff --git a/app/products/playbooks/actions/websocket/runs.test.ts b/app/products/playbooks/actions/websocket/runs.test.ts index 7e2d7800f..67bedec62 100644 --- a/app/products/playbooks/actions/websocket/runs.test.ts +++ b/app/products/playbooks/actions/websocket/runs.test.ts @@ -344,4 +344,54 @@ describe('handlePlaybookRunUpdatedIncremental', () => { expect(spyHandlePlaybookChecklist).toHaveBeenCalled(); expect(spyBatchRecords).toHaveBeenCalled(); }); + + it('should handle updates with no checklist items', async () => { + await operator.handlePlaybookRun({ + prepareRecordsOnly: false, + runs: mockPlaybookList, + processChildren: true, + }); + clearSpies(); + + const update = createFakeUpdateFromRun(mockPlaybookRun); + update.changed_fields.checklists = undefined; + + const msg = TestHelper.fakeWebsocketMessage({ + data: {payload: JSON.stringify(update)}, + }); + + jest.mocked(EphemeralStore.getChannelPlaybooksSynced).mockReturnValue(true); + + await handlePlaybookRunUpdatedIncremental(serverUrl, msg); + + expect(spyHandlePlaybookChecklist).not.toHaveBeenCalled(); + expect(spyHandlePlaybookRun).toHaveBeenCalled(); + expect(spyHandlePlaybookChecklistItem).not.toHaveBeenCalled(); + expect(spyBatchRecords).toHaveBeenCalled(); + }); + + it('should handle updates with no updates at all', async () => { + await operator.handlePlaybookRun({ + prepareRecordsOnly: false, + runs: mockPlaybookList, + processChildren: true, + }); + clearSpies(); + + const update = createFakeUpdateFromRun(mockPlaybookRun); + update.changed_fields = {}; + + const msg = TestHelper.fakeWebsocketMessage({ + data: {payload: JSON.stringify(update)}, + }); + + jest.mocked(EphemeralStore.getChannelPlaybooksSynced).mockReturnValue(true); + + await handlePlaybookRunUpdatedIncremental(serverUrl, msg); + + expect(spyHandlePlaybookChecklist).not.toHaveBeenCalled(); + expect(spyHandlePlaybookRun).not.toHaveBeenCalled(); + expect(spyHandlePlaybookChecklistItem).not.toHaveBeenCalled(); + expect(spyBatchRecords).not.toHaveBeenCalled(); + }); }); diff --git a/app/products/playbooks/actions/websocket/runs.ts b/app/products/playbooks/actions/websocket/runs.ts index dc9952d63..dbc35a7cd 100644 --- a/app/products/playbooks/actions/websocket/runs.ts +++ b/app/products/playbooks/actions/websocket/runs.ts @@ -116,11 +116,12 @@ const handlePlaybookChecklistUpdated = async (serverUrl: string, checklistUpdate const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); const models: Model[] = []; - const hasChecklistChangedFields = Object.keys(checklistUpdate.fields || {}).length > 0; + const fields = checklistUpdate.fields || {}; + const hasChecklistChangedFields = Object.keys(fields).length > 0; if (hasChecklistChangedFields) { const checklistModels = await operator.handlePlaybookChecklist({ checklists: [{ - ...(checklistUpdate.fields || {}), + ...fields, items_order: checklistUpdate.items_order, items: undefined, // Remove the items from the update id: checklistUpdate.id, diff --git a/app/products/playbooks/client/rest.test.ts b/app/products/playbooks/client/rest.test.ts index bd6864b5c..9e9f33d99 100644 --- a/app/products/playbooks/client/rest.test.ts +++ b/app/products/playbooks/client/rest.test.ts @@ -1,6 +1,8 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +/* eslint-disable max-lines */ + import TestHelper from '@test/test_helper'; import {buildQueryString} from '@utils/helpers'; @@ -63,6 +65,82 @@ describe('fetchPlaybookRuns', () => { }); }); +describe('fetchPlaybookRun', () => { + test('should fetch playbook run with id and groupLabel', async () => { + const id = 'run123'; + const groupLabel: RequestGroupLabel = 'Cold Start'; + const expectedUrl = `/plugins/playbooks/api/v0/runs/${id}`; + const expectedOptions = {method: 'get', groupLabel}; + const mockResponse = TestHelper.fakePlaybookRun({ + id, + name: 'Test Run', + }); + + jest.mocked(client.doFetch).mockResolvedValue(mockResponse); + + const result = await client.fetchPlaybookRun(id, groupLabel); + + expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions); + expect(result).toEqual(mockResponse); + }); + + test('should fetch playbook run with id only', async () => { + const id = 'run123'; + const expectedUrl = `/plugins/playbooks/api/v0/runs/${id}`; + const expectedOptions = {method: 'get', groupLabel: undefined}; + const mockResponse = TestHelper.fakePlaybookRun({ + id, + name: 'Test Run', + }); + + jest.mocked(client.doFetch).mockResolvedValue(mockResponse); + + const result = await client.fetchPlaybookRun(id); + + expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions); + expect(result).toEqual(mockResponse); + }); + + test('should handle error when fetching playbook run', async () => { + const id = 'run123'; + + jest.mocked(client.doFetch).mockRejectedValue(new Error('Network error')); + + await expect(client.fetchPlaybookRun(id)).rejects.toThrow('Network error'); + }); +}); + +describe('fetchPlaybookRunMetadata', () => { + test('should fetch playbook run metadata successfully', async () => { + const id = 'run123'; + const expectedUrl = `/plugins/playbooks/api/v0/runs/${id}/metadata`; + const expectedOptions = {method: 'get'}; + const mockResponse = TestHelper.fakePlaybookRunMetadata({ + channel_name: 'test-channel', + channel_display_name: 'Test Channel', + team_name: 'test-team', + num_participants: 3, + total_posts: 5, + followers: ['user1', 'user2'], + }); + + jest.mocked(client.doFetch).mockResolvedValue(mockResponse); + + const result = await client.fetchPlaybookRunMetadata(id); + + expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions); + expect(result).toEqual(mockResponse); + }); + + test('should handle error when fetching playbook run metadata', async () => { + const id = 'run123'; + + jest.mocked(client.doFetch).mockRejectedValue(new Error('Network error')); + + await expect(client.fetchPlaybookRunMetadata(id)).rejects.toThrow('Network error'); + }); +}); + describe('setChecklistItemState', () => { test('should set checklist item state successfully', async () => { const playbookRunID = 'run123'; @@ -437,3 +515,453 @@ describe('finishRun', () => { await expect(client.finishRun(playbookRunId)).rejects.toThrow('Network error'); }); }); + +describe('postStatusUpdate', () => { + test('should post status update successfully', async () => { + const playbookRunID = 'run123'; + const payload: PostStatusUpdatePayload = { + message: 'Status update message', + reminder: 3600, + finishRun: false, + }; + const ids: PostStatusUpdateIds = { + user_id: 'user123', + channel_id: 'channel123', + team_id: 'team123', + }; + const expectedUrl = `/plugins/playbooks/api/v0/runs/${playbookRunID}/update-status-dialog`; + const expectedBody = { + type: 'dialog_submission', + callback_id: '', + state: '', + cancelled: false, + ...ids, + submission: { + message: payload.message, + reminder: payload.reminder?.toFixed() ?? '', + finish_run: payload.finishRun, + }, + }; + const expectedOptions = {method: 'post', body: expectedBody}; + + jest.mocked(client.doFetch).mockResolvedValue(undefined); + + await client.postStatusUpdate(playbookRunID, payload, ids); + + expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions); + }); + + test('should post status update with finishRun true', async () => { + const playbookRunID = 'run123'; + const payload: PostStatusUpdatePayload = { + message: 'Finishing run', + finishRun: true, + }; + const ids: PostStatusUpdateIds = { + user_id: 'user123', + channel_id: 'channel123', + team_id: 'team123', + }; + const expectedUrl = `/plugins/playbooks/api/v0/runs/${playbookRunID}/update-status-dialog`; + const expectedBody = { + type: 'dialog_submission', + callback_id: '', + state: '', + cancelled: false, + ...ids, + submission: { + message: payload.message, + reminder: '', + finish_run: true, + }, + }; + const expectedOptions = {method: 'post', body: expectedBody}; + + jest.mocked(client.doFetch).mockResolvedValue(undefined); + + await client.postStatusUpdate(playbookRunID, payload, ids); + + expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions); + }); + + test('should post status update with reminder', async () => { + const playbookRunID = 'run123'; + const payload: PostStatusUpdatePayload = { + message: 'Status update with reminder', + reminder: 7200, + finishRun: false, + }; + const ids: PostStatusUpdateIds = { + user_id: 'user123', + channel_id: 'channel123', + team_id: 'team123', + }; + const expectedUrl = `/plugins/playbooks/api/v0/runs/${playbookRunID}/update-status-dialog`; + const expectedBody = { + type: 'dialog_submission', + callback_id: '', + state: '', + cancelled: false, + ...ids, + submission: { + message: payload.message, + reminder: '7200', + finish_run: false, + }, + }; + const expectedOptions = {method: 'post', body: expectedBody}; + + jest.mocked(client.doFetch).mockResolvedValue(undefined); + + await client.postStatusUpdate(playbookRunID, payload, ids); + + expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions); + }); + + test('should post status update with empty message', async () => { + const playbookRunID = 'run123'; + const payload: PostStatusUpdatePayload = { + message: '', + finishRun: false, + }; + const ids: PostStatusUpdateIds = { + user_id: 'user123', + channel_id: 'channel123', + team_id: 'team123', + }; + const expectedUrl = `/plugins/playbooks/api/v0/runs/${playbookRunID}/update-status-dialog`; + const expectedBody = { + type: 'dialog_submission', + callback_id: '', + state: '', + cancelled: false, + ...ids, + submission: { + message: '', + reminder: '', + finish_run: false, + }, + }; + const expectedOptions = {method: 'post', body: expectedBody}; + + jest.mocked(client.doFetch).mockResolvedValue(undefined); + + await client.postStatusUpdate(playbookRunID, payload, ids); + + expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions); + }); + + test('should handle error when posting status update', async () => { + const playbookRunID = 'run123'; + const payload: PostStatusUpdatePayload = { + message: 'Status update', + finishRun: false, + }; + const ids: PostStatusUpdateIds = { + user_id: 'user123', + channel_id: 'channel123', + team_id: 'team123', + }; + + jest.mocked(client.doFetch).mockRejectedValue(new Error('Network error')); + + await expect(client.postStatusUpdate(playbookRunID, payload, ids)).rejects.toThrow('Network error'); + }); +}); + +describe('fetchPlaybooks', () => { + test('should fetch playbooks with basic params', async () => { + const params: FetchPlaybooksParams = {team_id: 'team1'}; + const queryParams = buildQueryString(params); + const expectedUrl = `/plugins/playbooks/api/v0/playbooks${queryParams}`; + const expectedOptions = {method: 'get'}; + const mockResponse: FetchPlaybooksReturn = { + total_count: 2, + page_count: 1, + has_more: false, + items: [], + }; + + jest.mocked(client.doFetch).mockResolvedValue(mockResponse); + + const result = await client.fetchPlaybooks(params); + + expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions); + expect(result).toEqual(mockResponse); + }); + + test('should fetch playbooks with all params', async () => { + const params: FetchPlaybooksParams = { + team_id: 'team1', + page: 1, + per_page: 20, + sort: 'title', + direction: 'asc', + search_term: 'test search', + with_archived: true, + }; + const queryParams = buildQueryString(params); + const expectedUrl = `/plugins/playbooks/api/v0/playbooks${queryParams}`; + const expectedOptions = {method: 'get'}; + const mockResponse: FetchPlaybooksReturn = { + total_count: 5, + page_count: 1, + has_more: false, + items: [], + }; + + jest.mocked(client.doFetch).mockResolvedValue(mockResponse); + + const result = await client.fetchPlaybooks(params); + + expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions); + expect(result).toEqual(mockResponse); + }); + + test('should fetch playbooks with optional params only', async () => { + const params: FetchPlaybooksParams = { + team_id: 'team1', + page: 0, + per_page: 10, + sort: 'last_run_at', + direction: 'desc', + }; + const queryParams = buildQueryString(params); + const expectedUrl = `/plugins/playbooks/api/v0/playbooks${queryParams}`; + const expectedOptions = {method: 'get'}; + const mockResponse: FetchPlaybooksReturn = { + total_count: 3, + page_count: 1, + has_more: false, + items: [], + }; + + jest.mocked(client.doFetch).mockResolvedValue(mockResponse); + + const result = await client.fetchPlaybooks(params); + + expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions); + expect(result).toEqual(mockResponse); + }); + + test('should handle error when fetching playbooks', async () => { + const params: FetchPlaybooksParams = {team_id: 'team1'}; + + jest.mocked(client.doFetch).mockRejectedValue(new Error('Network error')); + + await expect(client.fetchPlaybooks(params)).rejects.toThrow('Network error'); + }); + + test('should fetch playbooks with search_term and with_archived', async () => { + const params: FetchPlaybooksParams = { + team_id: 'team1', + search_term: 'incident', + with_archived: false, + }; + const queryParams = buildQueryString(params); + const expectedUrl = `/plugins/playbooks/api/v0/playbooks${queryParams}`; + const expectedOptions = {method: 'get'}; + const mockResponse: FetchPlaybooksReturn = { + total_count: 1, + page_count: 1, + has_more: false, + items: [], + }; + + jest.mocked(client.doFetch).mockResolvedValue(mockResponse); + + const result = await client.fetchPlaybooks(params); + + expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions); + expect(result).toEqual(mockResponse); + }); +}); + +describe('createPlaybookRun', () => { + test('should create playbook run with all required params', async () => { + const playbook_id = 'playbook123'; + const owner_user_id = 'user123'; + const team_id = 'team123'; + const name = 'Test Run'; + const description = 'Test Description'; + const expectedUrl = '/plugins/playbooks/api/v0/runs'; + const expectedOptions = { + method: 'post', + body: { + owner_user_id, + team_id, + name, + description, + playbook_id, + channel_id: undefined, + create_public_run: undefined, + }, + }; + const mockResponse = TestHelper.fakePlaybookRun({ + id: 'run123', + name, + description, + playbook_id, + owner_user_id, + team_id, + }); + + jest.mocked(client.doFetch).mockResolvedValue(mockResponse); + + const result = await client.createPlaybookRun( + playbook_id, + owner_user_id, + team_id, + name, + description, + ); + + expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions); + expect(result).toEqual(mockResponse); + }); + + test('should create playbook run with all params including optional', async () => { + const playbook_id = 'playbook123'; + const owner_user_id = 'user123'; + const team_id = 'team123'; + const name = 'Test Run'; + const description = 'Test Description'; + const channel_id = 'channel123'; + const create_public_run = true; + const expectedUrl = '/plugins/playbooks/api/v0/runs'; + const expectedOptions = { + method: 'post', + body: { + owner_user_id, + team_id, + name, + description, + playbook_id, + channel_id, + create_public_run, + }, + }; + const mockResponse = { + id: 'run123', + name, + description, + playbook_id, + owner_user_id, + team_id, + channel_id, + create_public_run, + }; + + jest.mocked(client.doFetch).mockResolvedValue(mockResponse); + + const result = await client.createPlaybookRun( + playbook_id, + owner_user_id, + team_id, + name, + description, + channel_id, + create_public_run, + ); + + expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions); + expect(result).toEqual(mockResponse); + }); + + test('should create playbook run with channel_id only', async () => { + const playbook_id = 'playbook123'; + const owner_user_id = 'user123'; + const team_id = 'team123'; + const name = 'Test Run'; + const description = 'Test Description'; + const channel_id = 'channel123'; + const expectedUrl = '/plugins/playbooks/api/v0/runs'; + const expectedOptions = { + method: 'post', + body: { + owner_user_id, + team_id, + name, + description, + playbook_id, + channel_id, + create_public_run: undefined, + }, + }; + const mockResponse = { + id: 'run123', + name, + playbook_id, + }; + + jest.mocked(client.doFetch).mockResolvedValue(mockResponse); + + const result = await client.createPlaybookRun( + playbook_id, + owner_user_id, + team_id, + name, + description, + channel_id, + ); + + expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions); + expect(result).toEqual(mockResponse); + }); + + test('should create playbook run with create_public_run only', async () => { + const playbook_id = 'playbook123'; + const owner_user_id = 'user123'; + const team_id = 'team123'; + const name = 'Test Run'; + const description = 'Test Description'; + const create_public_run = false; + const expectedUrl = '/plugins/playbooks/api/v0/runs'; + const expectedOptions = { + method: 'post', + body: { + owner_user_id, + team_id, + name, + description, + playbook_id, + channel_id: undefined, + create_public_run, + }, + }; + const mockResponse = { + id: 'run123', + name, + playbook_id, + }; + + jest.mocked(client.doFetch).mockResolvedValue(mockResponse); + + const result = await client.createPlaybookRun( + playbook_id, + owner_user_id, + team_id, + name, + description, + undefined, + create_public_run, + ); + + expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions); + expect(result).toEqual(mockResponse); + }); + + test('should handle error when creating playbook run', async () => { + const playbook_id = 'playbook123'; + const owner_user_id = 'user123'; + const team_id = 'team123'; + const name = 'Test Run'; + const description = 'Test Description'; + + jest.mocked(client.doFetch).mockRejectedValue(new Error('Network error')); + + await expect( + client.createPlaybookRun(playbook_id, owner_user_id, team_id, name, description), + ).rejects.toThrow('Network error'); + }); +}); diff --git a/app/products/playbooks/screens/playbooks_runs/empty_state.test.tsx b/app/products/playbooks/components/run_list/empty_state.test.tsx similarity index 100% rename from app/products/playbooks/screens/playbooks_runs/empty_state.test.tsx rename to app/products/playbooks/components/run_list/empty_state.test.tsx diff --git a/app/products/playbooks/screens/playbooks_runs/empty_state.tsx b/app/products/playbooks/components/run_list/empty_state.tsx similarity index 100% rename from app/products/playbooks/screens/playbooks_runs/empty_state.tsx rename to app/products/playbooks/components/run_list/empty_state.tsx diff --git a/app/products/playbooks/screens/playbooks_runs/empty_state_icon.test.tsx b/app/products/playbooks/components/run_list/empty_state_icon.test.tsx similarity index 100% rename from app/products/playbooks/screens/playbooks_runs/empty_state_icon.test.tsx rename to app/products/playbooks/components/run_list/empty_state_icon.test.tsx diff --git a/app/products/playbooks/screens/playbooks_runs/empty_state_icon.tsx b/app/products/playbooks/components/run_list/empty_state_icon.tsx similarity index 100% rename from app/products/playbooks/screens/playbooks_runs/empty_state_icon.tsx rename to app/products/playbooks/components/run_list/empty_state_icon.tsx diff --git a/app/products/playbooks/components/run_list/index.ts b/app/products/playbooks/components/run_list/index.ts new file mode 100644 index 000000000..a583e8450 --- /dev/null +++ b/app/products/playbooks/components/run_list/index.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import RunList from './run_list'; + +import type {RunListTabsNames} from './types'; + +export default RunList; +export type {RunListTabsNames}; diff --git a/app/products/playbooks/screens/playbooks_runs/playbook_card/index.test.tsx b/app/products/playbooks/components/run_list/playbook_card/index.test.tsx similarity index 100% rename from app/products/playbooks/screens/playbooks_runs/playbook_card/index.test.tsx rename to app/products/playbooks/components/run_list/playbook_card/index.test.tsx diff --git a/app/products/playbooks/screens/playbooks_runs/playbook_card/index.ts b/app/products/playbooks/components/run_list/playbook_card/index.ts similarity index 100% rename from app/products/playbooks/screens/playbooks_runs/playbook_card/index.ts rename to app/products/playbooks/components/run_list/playbook_card/index.ts diff --git a/app/products/playbooks/screens/playbooks_runs/playbook_card/playbook_card.test.tsx b/app/products/playbooks/components/run_list/playbook_card/playbook_card.test.tsx similarity index 73% rename from app/products/playbooks/screens/playbooks_runs/playbook_card/playbook_card.test.tsx rename to app/products/playbooks/components/run_list/playbook_card/playbook_card.test.tsx index b972f2307..63bf05dbe 100644 --- a/app/products/playbooks/screens/playbooks_runs/playbook_card/playbook_card.test.tsx +++ b/app/products/playbooks/components/run_list/playbook_card/playbook_card.test.tsx @@ -74,6 +74,32 @@ describe('PlaybookCard', () => { expect(progressBar.props.isActive).toBe(true); }); + it('renders all components correctly with API items', () => { + const props = getBaseProps(); + props.run = TestHelper.fakePlaybookRun({ + name: 'Test Playbook Run', + update_at: Date.now() - 1000, + channel_id: 'test-channel-id', + }); + const {getByTestId, getByText} = renderWithIntl(); + expect(getByText('Test Playbook Run')).toBeTruthy(); + expect(getByText(/Last update/)).toBeTruthy(); + + const userChip = getByTestId('user-chip'); + expect(userChip.props.user).toBe(props.owner); + expect(userChip.props.teammateNameDisplay).toBe('username'); + + const userAvatarsStack = getByTestId('user-avatars-stack'); + expect(userAvatarsStack.props.users).toEqual(props.participants); + expect(userAvatarsStack.props.channelId).toBe(props.run.channel_id); + expect(userAvatarsStack.props.location).toBe(props.location); + expect(userAvatarsStack.props.bottomSheetTitle.defaultMessage).toBe('Run Participants'); + + const progressBar = getByTestId('progress-bar'); + expect(progressBar.props.progress).toBe(50); + expect(progressBar.props.isActive).toBe(true); + }); + it('should open user profile modal on user chip press', () => { const props = getBaseProps(); const {getByTestId} = renderWithIntl(); @@ -104,6 +130,28 @@ describe('PlaybookCard', () => { expect(goToPlaybookRunWithChannelSwitch).not.toHaveBeenCalled(); }); + it('navigates to playbook run on press for regular location with API items', () => { + const props = getBaseProps(); + props.run = TestHelper.fakePlaybookRun({ + id: 'test-run-id', + name: 'Test Playbook Run', + update_at: Date.now() - 1000, + channel_id: 'test-channel-id', + }); + const {getByText} = renderWithIntl(); + + act(() => { + fireEvent.press(getByText('Test Playbook Run')); + }); + + expect(goToPlaybookRun).toHaveBeenCalledWith( + expect.anything(), + props.run.id, + props.run, + ); + expect(goToPlaybookRunWithChannelSwitch).not.toHaveBeenCalled(); + }); + it('navigates to playbook run with channel switch when location is PARTICIPANT_PLAYBOOKS', () => { const props = getBaseProps(); props.location = 'ParticipantPlaybooks'; diff --git a/app/products/playbooks/screens/playbooks_runs/playbook_card/playbook_card.tsx b/app/products/playbooks/components/run_list/playbook_card/playbook_card.tsx similarity index 100% rename from app/products/playbooks/screens/playbooks_runs/playbook_card/playbook_card.tsx rename to app/products/playbooks/components/run_list/playbook_card/playbook_card.tsx diff --git a/app/products/playbooks/components/run_list/run_list.test.tsx b/app/products/playbooks/components/run_list/run_list.test.tsx new file mode 100644 index 000000000..4db5c5403 --- /dev/null +++ b/app/products/playbooks/components/run_list/run_list.test.tsx @@ -0,0 +1,357 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {act, fireEvent, waitFor} from '@testing-library/react-native'; +import React, {type ComponentProps} from 'react'; + +import DatabaseManager from '@database/manager'; +import {goToSelectPlaybook} from '@playbooks/screens/navigation'; +import {renderWithEverything, renderWithIntlAndTheme} from '@test/intl-test-helper'; +import TestHelper from '@test/test_helper'; + +import EmptyState from './empty_state'; +import PlaybookCard from './playbook_card'; +import RunList from './run_list'; +import ShowMoreButton from './show_more_button'; + +import type {AvailableScreens} from '@typings/screens/navigation'; + +jest.mock('./empty_state'); +jest.mocked(EmptyState).mockImplementation((props) => React.createElement('EmptyState', {...props, testID: 'empty-state'})); + +jest.mock('./playbook_card'); +jest.mocked(PlaybookCard).mockImplementation((props) => React.createElement('PlaybookCard', {...props, testID: 'playbook-card'})); + +jest.mock('./show_more_button'); +jest.mocked(ShowMoreButton).mockImplementation((props) => React.createElement('ShowMoreButton', {...props, testID: 'show-more-button'})); + +jest.mock('@playbooks/screens/navigation', () => ({ + goToSelectPlaybook: jest.fn(), +})); + +describe('RunList', () => { + const componentId = 'TestScreen' as AvailableScreens; + const inProgressRun = TestHelper.fakePlaybookRunModel({ + id: 'in-progress-1', + name: 'In Progress Run', + }); + + const finishedRun = TestHelper.fakePlaybookRunModel({ + id: 'finished-1', + name: 'Finished Run', + }); + + function getBaseProps(): ComponentProps { + return { + componentId, + inProgressRuns: [], + finishedRuns: [], + fetchMoreRuns: jest.fn(), + showMoreButton: jest.fn(() => false), + fetching: false, + }; + } + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders loading state when loading is true', () => { + const props = getBaseProps(); + props.loading = true; + const {getByTestId} = renderWithIntlAndTheme(); + + expect(getByTestId('loading')).toBeTruthy(); + }); + + it('renders empty state for in-progress tab when no runs', () => { + const props = getBaseProps(); + props.inProgressRuns = []; + props.finishedRuns = []; + const {getByTestId} = renderWithIntlAndTheme(); + + const emptyState = getByTestId('empty-state'); + expect(emptyState).toBeTruthy(); + expect(emptyState.props.tab).toBe('in-progress'); + }); + + it('renders empty state for finished tab when switching tabs', async () => { + const props = getBaseProps(); + props.inProgressRuns = [inProgressRun]; + props.finishedRuns = []; + const {getByText, getByTestId} = renderWithIntlAndTheme(); + + // Switch to finished tab + act(() => { + fireEvent.press(getByText('Finished')); + }); + + await waitFor(() => { + const emptyState = getByTestId('empty-state'); + expect(emptyState).toBeTruthy(); + expect(emptyState.props.tab).toBe('finished'); + }); + }); + + it('renders runs list when runs are available', () => { + const props = getBaseProps(); + props.inProgressRuns = [inProgressRun]; + props.finishedRuns = []; + const {getByTestId} = renderWithIntlAndTheme(); + + expect(getByTestId('runs.list')).toBeTruthy(); + }); + + it('displays in-progress runs when in-progress tab is active', () => { + const props = getBaseProps(); + props.inProgressRuns = [inProgressRun]; + props.finishedRuns = []; + const {getByTestId} = renderWithIntlAndTheme(); + + const flashList = getByTestId('runs.list'); + expect(flashList.props.data).toHaveLength(1); + expect(flashList.props.data[0]).toBe(inProgressRun); + }); + + it('displays finished runs when finished tab is active', async () => { + const props = getBaseProps(); + props.inProgressRuns = [inProgressRun]; + props.finishedRuns = [finishedRun]; + const {getByText, getByTestId} = renderWithIntlAndTheme(); + + // Switch to finished tab + act(() => { + fireEvent.press(getByText('Finished')); + }); + + await waitFor(() => { + const flashList = getByTestId('runs.list'); + expect(flashList.props.data).toHaveLength(1); + expect(flashList.props.data[0]).toBe(finishedRun); + }); + }); + + it('switches between tabs correctly', async () => { + const props = getBaseProps(); + props.inProgressRuns = [inProgressRun]; + props.finishedRuns = [finishedRun]; + const {getByText, getByTestId} = renderWithIntlAndTheme(); + + // Initially shows in-progress runs + let flashList = getByTestId('runs.list'); + expect(flashList.props.data).toHaveLength(1); + expect(flashList.props.data[0]).toBe(inProgressRun); + + // Switch to finished tab + act(() => { + fireEvent.press(getByText('Finished')); + }); + + await waitFor(() => { + flashList = getByTestId('runs.list'); + expect(flashList.props.data).toHaveLength(1); + expect(flashList.props.data[0]).toBe(finishedRun); + }); + + // Switch back to in-progress tab + act(() => { + fireEvent.press(getByText('In Progress')); + }); + + await waitFor(() => { + flashList = getByTestId('runs.list'); + expect(flashList.props.data).toHaveLength(1); + expect(flashList.props.data[0]).toBe(inProgressRun); + }); + }); + + it('defaults to finished tab when no in-progress runs', () => { + const props = getBaseProps(); + props.inProgressRuns = []; + props.finishedRuns = [finishedRun]; + const {getByTestId} = renderWithIntlAndTheme(); + + const flashList = getByTestId('runs.list'); + expect(flashList.props.data).toHaveLength(1); + expect(flashList.props.data[0]).toBe(finishedRun); + }); + + it('shows show more button when showMoreButton returns true', () => { + const props = getBaseProps(); + props.inProgressRuns = [inProgressRun]; + props.showMoreButton = jest.fn((tab) => tab === 'in-progress'); + const {getByTestId, getByText, queryByTestId} = renderWithIntlAndTheme(); + + expect(getByTestId('show-more-button')).toBeTruthy(); + + // Switch to finished tab + act(() => { + fireEvent.press(getByText('Finished')); + }); + + expect(queryByTestId('show-more-button')).toBeNull(); + }); + + it('calls fetchMoreRuns when show more button is pressed', async () => { + const fetchMoreRuns = jest.fn(); + const props = getBaseProps(); + props.inProgressRuns = [inProgressRun]; + props.fetchMoreRuns = fetchMoreRuns; + props.showMoreButton = jest.fn((tab) => tab === 'in-progress'); + const {getByTestId} = renderWithIntlAndTheme(); + + const showMoreButton = getByTestId('show-more-button'); + act(() => { + fireEvent.press(showMoreButton); + }); + + await waitFor(() => { + expect(fetchMoreRuns).toHaveBeenCalledWith('in-progress'); + }); + }); + + it('passes fetching prop to ShowMoreButton', () => { + const props = getBaseProps(); + props.inProgressRuns = [inProgressRun]; + props.fetching = true; + props.showMoreButton = jest.fn(() => true); + const {getByTestId} = renderWithIntlAndTheme(); + + const showMoreButton = getByTestId('show-more-button'); + expect(showMoreButton.props.fetching).toBe(true); + }); + + it('renders Start a new run button', () => { + const props = getBaseProps(); + props.inProgressRuns = [inProgressRun]; + const {getByText} = renderWithIntlAndTheme(); + + expect(getByText('Start a new run')).toBeTruthy(); + }); + + it('calls goToSelectPlaybook when Start a new run button is pressed', () => { + const props = getBaseProps(); + props.inProgressRuns = [inProgressRun]; + const {getByText} = renderWithIntlAndTheme(); + + act(() => { + fireEvent.press(getByText('Start a new run')); + }); + + expect(goToSelectPlaybook).toHaveBeenCalledTimes(1); + expect(goToSelectPlaybook).toHaveBeenCalledWith( + expect.objectContaining({ + formatMessage: expect.any(Function), + }), + expect.anything(), + undefined, + ); + }); + + it('shows cached warning when showCachedWarning is true', async () => { + // This test needs the database because it uses a Markdown component + // underneath the SectionNotice component. + const serverUrl = 'server-url'; + await DatabaseManager.init([serverUrl]); + const database = DatabaseManager.getServerDatabaseAndOperator(serverUrl).database; + + const props = getBaseProps(); + props.inProgressRuns = [inProgressRun]; + props.showCachedWarning = true; + const {getByText} = renderWithEverything(, {database}); + + expect(getByText('Cannot reach the server')).toBeTruthy(); + }); + + it('hides cached warning when showCachedWarning is false', () => { + const props = getBaseProps(); + props.inProgressRuns = [inProgressRun]; + props.showCachedWarning = false; + const {queryByText} = renderWithIntlAndTheme(); + + expect(queryByText('Cannot reach the server')).toBeNull(); + }); + + it('renders multiple runs correctly', () => { + const run1 = TestHelper.fakePlaybookRunModel({id: 'run-1'}); + const run2 = TestHelper.fakePlaybookRunModel({id: 'run-2'}); + const run3 = TestHelper.fakePlaybookRunModel({id: 'run-3'}); + + const props = getBaseProps(); + props.inProgressRuns = [run1, run2, run3]; + props.finishedRuns = []; + const {getByTestId} = renderWithIntlAndTheme(); + + const flashList = getByTestId('runs.list'); + expect(flashList.props.data).toHaveLength(3); + expect(flashList.props.data).toEqual([run1, run2, run3]); + }); + + it('renders both in-progress and finished runs correctly', async () => { + const inProgressRun1 = TestHelper.fakePlaybookRunModel({id: 'in-progress-1'}); + const inProgressRun2 = TestHelper.fakePlaybookRunModel({id: 'in-progress-2'}); + const finishedRun1 = TestHelper.fakePlaybookRunModel({id: 'finished-1'}); + const finishedRun2 = TestHelper.fakePlaybookRunModel({id: 'finished-2'}); + + const props = getBaseProps(); + props.inProgressRuns = [inProgressRun1, inProgressRun2]; + props.finishedRuns = [finishedRun1, finishedRun2]; + const {getByText, getByTestId} = renderWithIntlAndTheme(); + + // Check in-progress tab + let flashList = getByTestId('runs.list'); + expect(flashList.props.data).toHaveLength(2); + expect(flashList.props.data).toEqual([inProgressRun1, inProgressRun2]); + + // Switch to finished tab + act(() => { + fireEvent.press(getByText('Finished')); + }); + + await waitFor(() => { + flashList = getByTestId('runs.list'); + expect(flashList.props.data).toHaveLength(2); + expect(flashList.props.data).toEqual([finishedRun1, finishedRun2]); + }); + }); + + it('calls fetchMoreRuns with correct tab when show more is pressed on finished tab', async () => { + const fetchMoreRuns = jest.fn(); + const props = getBaseProps(); + props.inProgressRuns = [inProgressRun]; + props.finishedRuns = [finishedRun]; + props.fetchMoreRuns = fetchMoreRuns; + props.showMoreButton = jest.fn((tab) => tab === 'finished'); + const {getByText, getByTestId} = renderWithIntlAndTheme(); + + // Switch to finished tab + act(() => { + fireEvent.press(getByText('Finished')); + }); + + await waitFor(() => { + const showMoreButton = getByTestId('show-more-button'); + act(() => { + fireEvent.press(showMoreButton); + }); + }); + + await waitFor(() => { + expect(fetchMoreRuns).toHaveBeenCalledWith('finished'); + }); + }); + + it('calls goToSelectPlaybook with correct channelId when Start a new run button is pressed', () => { + const props = getBaseProps(); + props.inProgressRuns = [inProgressRun]; + props.channelId = 'channel-id-1'; + const {getByText} = renderWithIntlAndTheme(); + + act(() => { + fireEvent.press(getByText('Start a new run')); + }); + + expect(goToSelectPlaybook).toHaveBeenCalledTimes(1); + expect(goToSelectPlaybook).toHaveBeenCalledWith(expect.anything(), expect.anything(), 'channel-id-1'); + }); +}); diff --git a/app/products/playbooks/components/run_list/run_list.tsx b/app/products/playbooks/components/run_list/run_list.tsx new file mode 100644 index 000000000..9638a2f29 --- /dev/null +++ b/app/products/playbooks/components/run_list/run_list.tsx @@ -0,0 +1,200 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {FlashList, type ListRenderItem} from '@shopify/flash-list'; +import React, {useCallback, useMemo} from 'react'; +import {defineMessages, useIntl} from 'react-intl'; +import {StyleSheet, View} from 'react-native'; +import {useSafeAreaInsets} from 'react-native-safe-area-context'; + +import Button from '@components/button'; +import Loading from '@components/loading'; +import SectionNotice from '@components/section_notice'; +import {useTheme} from '@context/theme'; +import useTabs, {type TabDefinition} from '@hooks/use_tabs'; +import Tabs from '@hooks/use_tabs/tabs'; +import {goToSelectPlaybook} from '@playbooks/screens/navigation'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +import EmptyState from './empty_state'; +import PlaybookCard, {CARD_HEIGHT} from './playbook_card'; +import ShowMoreButton from './show_more_button'; + +import type {RunListTabsNames} from './types'; +import type PlaybookRunModel from '@playbooks/types/database/models/playbook_run'; +import type {AvailableScreens} from '@typings/screens/navigation'; + +type Props = { + componentId: AvailableScreens; + inProgressRuns: Array; + finishedRuns: Array; + fetchMoreRuns: (tab: 'in-progress' | 'finished') => void; + showMoreButton: (tab: 'in-progress' | 'finished') => boolean; + loading?: boolean; + fetching: boolean; + showCachedWarning?: boolean; + channelId?: string; +}; + +const itemSeparatorStyle = StyleSheet.create({ + itemSeparator: { + height: 12, + }, +}); +const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => ({ + container: { + padding: 20, + }, + tabContainer: { + borderBottomWidth: 1, + borderBottomColor: changeOpacity(theme.centerChannelColor, 0.12), + }, + warningContainer: { + paddingTop: 8, + paddingHorizontal: 16, + }, + startANewRunButtonContainer: { + padding: 20, + }, +})); + +const ItemSeparator = () => { + return ; +}; + +const messages = defineMessages({ + cachedWarningTitle: { + id: 'playbooks.run_list.cached_warning_title', + defaultMessage: 'Cannot reach the server', + }, + cachedWarningMessage: { + id: 'playbooks.run_list.cached_warning_message', + defaultMessage: 'Showing cached data only. Some playbook runs or updates may be missing from this list.', + }, + tabInProgress: { + id: 'playbooks.run_list.tab_in_progress', + defaultMessage: 'In Progress', + }, + tabFinished: { + id: 'playbooks.run_list.tab_finished', + defaultMessage: 'Finished', + }, +}); + +const tabs: Array> = [ + { + id: 'in-progress', + name: messages.tabInProgress, + }, + { + id: 'finished', + name: messages.tabFinished, + }, +]; + +const RunList = ({ + componentId, + inProgressRuns, + finishedRuns, + fetchMoreRuns, + showMoreButton, + fetching, + loading = false, + showCachedWarning = false, + channelId, +}: Props) => { + const intl = useIntl(); + const theme = useTheme(); + const styles = getStyleFromTheme(theme); + const insets = useSafeAreaInsets(); + + let initialTab: RunListTabsNames = 'in-progress'; + if (!inProgressRuns.length && finishedRuns.length) { + initialTab = 'finished'; + } + const [activeTab, tabsProps] = useTabs(initialTab, tabs); + + const data = activeTab === 'in-progress' ? inProgressRuns : finishedRuns; + const isEmpty = data.length === 0; + + const onShowMorePress = useCallback(() => { + fetchMoreRuns(activeTab); + }, [fetchMoreRuns, activeTab]); + + const footerComponent = useMemo(() => ( + + ), [fetching, onShowMorePress, showMoreButton, activeTab]); + + const startANewRunButtonContainerStyle = useMemo(() => { + return [styles.startANewRunButtonContainer, {paddingBottom: insets.bottom}]; + }, [insets.bottom, styles]); + + const renderItem: ListRenderItem = useCallback(({item}) => { + return ( + + ); + }, [componentId]); + + const startANewRun = useCallback(() => { + goToSelectPlaybook(intl, theme, channelId); + }, [intl, theme, channelId]); + + let content; + if (loading) { + content = ; + } else if (isEmpty) { + content = (); + } else { + content = ( + <> + + + ); + } + + return ( + <> + + + + {showCachedWarning && ( + + + + )} + {content} + +