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}
+
+
+
+ >
+ );
+};
+
+export default RunList;
diff --git a/app/products/playbooks/components/run_list/show_more_button.test.tsx b/app/products/playbooks/components/run_list/show_more_button.test.tsx
new file mode 100644
index 000000000..093071b2b
--- /dev/null
+++ b/app/products/playbooks/components/run_list/show_more_button.test.tsx
@@ -0,0 +1,73 @@
+// 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, {type ComponentProps} from 'react';
+
+import {renderWithIntl} from '@test/intl-test-helper';
+
+import ShowMoreButton from './show_more_button';
+
+describe('ShowMoreButton', () => {
+ function getBaseProps(): ComponentProps {
+ return {
+ fetching: false,
+ onPress: jest.fn(),
+ visible: true,
+ };
+ }
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders correctly when visible', () => {
+ const props = getBaseProps();
+ const {getByText} = renderWithIntl();
+
+ const button = getByText('Show More');
+ expect(button).toBeTruthy();
+ });
+
+ it('calls onPress when button is pressed', async () => {
+ const props = getBaseProps();
+ const {getByText} = renderWithIntl();
+
+ await act(async () => {
+ fireEvent.press(getByText('Show More'));
+ });
+
+ expect(props.onPress).toHaveBeenCalled();
+ });
+
+ it('prevents double tap', async () => {
+ const props = getBaseProps();
+ const {getByText} = renderWithIntl();
+
+ await act(async () => {
+ fireEvent.press(getByText('Show More'));
+ });
+
+ await act(async () => {
+ fireEvent.press(getByText('Show More'));
+ });
+
+ expect(props.onPress).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not render when visible is false', () => {
+ const props = getBaseProps();
+ props.visible = false;
+
+ const {queryByText} = renderWithIntl();
+ expect(queryByText('Show More')).toBeNull();
+ });
+
+ it('shows loader when fetching is true', () => {
+ const props = getBaseProps();
+ props.fetching = true;
+
+ const {getByTestId} = renderWithIntl();
+ expect(getByTestId('show-more-button-loader')).toBeTruthy();
+ });
+});
diff --git a/app/products/playbooks/components/run_list/show_more_button.tsx b/app/products/playbooks/components/run_list/show_more_button.tsx
new file mode 100644
index 000000000..84029010c
--- /dev/null
+++ b/app/products/playbooks/components/run_list/show_more_button.tsx
@@ -0,0 +1,51 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {useIntl} from 'react-intl';
+import {StyleSheet, View} from 'react-native';
+
+import Button from '@components/button';
+import {useTheme} from '@context/theme';
+import {usePreventDoubleTap} from '@hooks/utils';
+
+type Props = {
+ fetching: boolean;
+ onPress: () => void;
+ visible: boolean;
+};
+
+const styles = StyleSheet.create({
+ container: {
+ paddingTop: 16,
+ },
+});
+
+const ShowMoreButton = ({
+ fetching,
+ onPress,
+ visible,
+}: Props) => {
+ const intl = useIntl();
+ const theme = useTheme();
+
+ if (!visible) {
+ return null;
+ }
+
+ const doubleTapPreventedOnPress = usePreventDoubleTap(onPress);
+
+ return (
+
+
+
+ );
+};
+
+export default ShowMoreButton;
diff --git a/app/products/playbooks/components/run_list/types.tsx b/app/products/playbooks/components/run_list/types.tsx
new file mode 100644
index 000000000..36234e9db
--- /dev/null
+++ b/app/products/playbooks/components/run_list/types.tsx
@@ -0,0 +1,4 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+export type RunListTabsNames = 'in-progress' | 'finished';
diff --git a/app/products/playbooks/components/status_update_post/index.test.tsx b/app/products/playbooks/components/status_update_post/index.test.tsx
new file mode 100644
index 000000000..6b096bf52
--- /dev/null
+++ b/app/products/playbooks/components/status_update_post/index.test.tsx
@@ -0,0 +1,164 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React, {type ComponentProps} from 'react';
+
+import {fetchUsersByIds} from '@actions/remote/user';
+import CompassIcon from '@components/compass_icon';
+import {Preferences} from '@constants';
+import {useServerUrl} from '@context/server';
+import DatabaseManager from '@database/manager';
+import {renderWithEverything} from '@test/intl-test-helper';
+import TestHelper from '@test/test_helper';
+
+import Participants from './participants';
+
+import StatusUpdatePost from './index';
+
+import type {Database} from '@nozbe/watermelondb';
+
+jest.mock('@actions/remote/user', () => ({
+ fetchUserOrGroupsByMentionsInBatch: jest.fn(),
+ fetchUsersByIds: jest.fn(),
+}));
+
+jest.mock('@components/compass_icon', () => ({
+ __esModule: true,
+ default: jest.fn(),
+}));
+jest.mocked(CompassIcon).mockImplementation(
+ (props) => React.createElement('CompassIcon', {testID: 'compass-icon', ...props}) as any, // override the type since it is expecting a class component
+);
+
+jest.mock('@context/server', () => ({
+ useServerUrl: jest.fn(() => 'server-url'),
+}));
+
+jest.mock('./participants', () => ({
+ __esModule: true,
+ default: jest.fn(),
+}));
+jest.mocked(Participants).mockImplementation(
+ (props) => React.createElement('Participants', {testID: 'participants', ...props}),
+);
+
+describe('StatusUpdatePost', () => {
+ function getBaseProps(): ComponentProps {
+ const post = TestHelper.fakePostModel({
+ id: 'post-id',
+ channelId: 'channel-id',
+ message: 'This is a status update message',
+ props: {
+ authorUsername: 'john.doe',
+ numTasks: 10,
+ numTasksChecked: 7,
+ participantIds: ['user-1', 'user-2', 'user-3'],
+ playbookRunId: 'run-id',
+ runName: 'Test Run',
+ },
+ });
+
+ return {
+ location: 'Channel',
+ post,
+ theme: Preferences.THEMES.denim,
+ };
+ }
+
+ let database: Database;
+ const serverUrl = 'server-url';
+ beforeEach(async () => {
+ jest.clearAllMocks();
+ jest.mocked(useServerUrl).mockReturnValue('https://server-url.com');
+ jest.mocked(fetchUsersByIds).mockResolvedValue({users: [], existingUsers: []});
+ await DatabaseManager.init([serverUrl]);
+ database = DatabaseManager.getServerDatabaseAndOperator(serverUrl).database;
+ });
+
+ afterEach(async () => {
+ await DatabaseManager.destroyServerDatabase(serverUrl);
+ });
+
+ it('should render correctly with default props', () => {
+ const props = getBaseProps();
+ const {getByTestId, getByText} = renderWithEverything(, {database});
+
+ expect(getByText(/posted an update for/)).toBeTruthy();
+ expect(getByTestId('participants')).toBeTruthy();
+ });
+
+ it('should fetch users by ids on mount', () => {
+ const props = getBaseProps();
+ renderWithEverything(, {database});
+
+ expect(fetchUsersByIds).toHaveBeenCalledWith(
+ 'https://server-url.com',
+ ['user-1', 'user-2', 'user-3'],
+ );
+ });
+
+ it('should render markdown with update posted message', () => {
+ const props = getBaseProps();
+ const {getByText} = renderWithEverything(, {database});
+
+ expect(getByText('@john.doe posted an update for Test Run')).toBeTruthy();
+ });
+
+ it('should render markdown with post message', () => {
+ const props = getBaseProps();
+ const {getByText} = renderWithEverything(, {database});
+
+ expect(getByText('This is a status update message')).toBeTruthy();
+ });
+
+ it('should render tasks information with correct icon', () => {
+ const props = getBaseProps();
+ const {getByTestId} = renderWithEverything(, {database});
+
+ const icon = getByTestId('compass-icon');
+ expect(icon).toHaveProp('name', 'check-all');
+ });
+
+ it('should render tasks information with correct counts', () => {
+ const props = getBaseProps();
+ const {getByText} = renderWithEverything(, {database});
+
+ expect(getByText('7 of 10 tasks checked')).toBeTruthy();
+ });
+
+ it('should render participants component with correct participant ids', () => {
+ const props = getBaseProps();
+ const {getByTestId} = renderWithEverything(, {database});
+
+ const participants = getByTestId('participants');
+ expect(participants).toBeTruthy();
+ expect(participants).toHaveProp('participantIds', ['user-1', 'user-2', 'user-3']);
+ });
+
+ it('should handle single task', () => {
+ const props = getBaseProps();
+ const post = TestHelper.fakePostModel({
+ ...props.post,
+ props: {
+ ...props.post.props,
+ numTasks: 1,
+ numTasksChecked: 1,
+ },
+ });
+ props.post = post;
+ const {getByText} = renderWithEverything(, {database});
+
+ expect(getByText('1 of 1 task checked')).toBeTruthy();
+ });
+
+ it('should handle invalid status update props', () => {
+ const props = getBaseProps();
+ props.post = TestHelper.fakePostModel({
+ ...props.post,
+ props: {},
+ });
+ const {getByText} = renderWithEverything(, {database});
+
+ expect(getByText('Playbooks status update post with invalid properties')).toBeTruthy();
+ });
+});
diff --git a/app/products/playbooks/components/status_update_post/index.tsx b/app/products/playbooks/components/status_update_post/index.tsx
index ffd3f75f0..92dbaed09 100644
--- a/app/products/playbooks/components/status_update_post/index.tsx
+++ b/app/products/playbooks/components/status_update_post/index.tsx
@@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
-import React, {useEffect, useMemo} from 'react';
+import React, {useEffect} from 'react';
import {useIntl} from 'react-intl';
import {Text, View} from 'react-native';
@@ -9,11 +9,13 @@ import {fetchUsersByIds} from '@actions/remote/user';
import CompassIcon from '@components/compass_icon';
import Markdown from '@components/markdown';
import {useServerUrl} from '@context/server';
+import {isPostStatusUpdateProps} from '@playbooks/utils/types';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import Participants from './participants';
+import type {PostStatusUpdateProps} from '@playbooks/types/post';
import type PostModel from '@typings/database/models/servers/post';
import type {AvailableScreens} from '@typings/screens/navigation';
@@ -23,15 +25,6 @@ type Props = {
theme: Theme;
};
-type StatusUpdatePostProps = {
- authorUsername: string;
- numTasks: number;
- numTasksChecked: number;
- participantIds: string[];
- playbookRunId: string;
- runName: string;
-};
-
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
messageContainer: {
@@ -84,18 +77,41 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
});
const StatusUpdatePost = ({location, post, theme}: Props) => {
- const {authorUsername, numTasks, numTasksChecked, participantIds, playbookRunId, runName} = post.props as StatusUpdatePostProps;
+ let statusUpdateProps: PostStatusUpdateProps | undefined;
+
+ if (isPostStatusUpdateProps(post.props)) {
+ statusUpdateProps = post.props;
+ }
+
const serverUrl = useServerUrl();
const style = getStyleSheet(theme);
const intl = useIntl();
useEffect(() => {
- fetchUsersByIds(serverUrl, participantIds);
+ if (statusUpdateProps) {
+ fetchUsersByIds(serverUrl, statusUpdateProps.participantIds);
+ }
// Only do this on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
+ if (!statusUpdateProps) {
+ return (
+
+
+
+ );
+ }
+
+ const {authorUsername, numTasks, numTasksChecked, participantIds, playbookRunId, runName} = statusUpdateProps;
const updatePosted = intl.formatMessage({
id: 'playbooks.status_update_post.update',
defaultMessage: '@{authorUsername} posted an update for [{runName}]({link})',
@@ -105,10 +121,6 @@ const StatusUpdatePost = ({location, post, theme}: Props) => {
defaultMessage: '**{numTasksChecked, number}** of **{numTasks, number}** {numTasks, plural, =1 {task} other {tasks}} checked',
}, {numTasksChecked, numTasks});
- const mentionKeys = useMemo(() => {
- return authorUsername ? [{key: authorUsername, caseSensitive: false}] : [];
- }, [authorUsername]);
-
return (
{
channelId={post.channelId}
postId={post.id}
value={updatePosted}
- mentionKeys={mentionKeys}
theme={theme}
location={location}
/>
diff --git a/app/products/playbooks/components/status_update_post/participants/index.test.tsx b/app/products/playbooks/components/status_update_post/participants/index.test.tsx
new file mode 100644
index 000000000..c37605d2f
--- /dev/null
+++ b/app/products/playbooks/components/status_update_post/participants/index.test.tsx
@@ -0,0 +1,105 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React, {type ComponentProps} from 'react';
+
+import DatabaseManager from '@database/manager';
+import {renderWithEverything} from '@test/intl-test-helper';
+import TestHelper from '@test/test_helper';
+
+import Participants from './participants';
+
+import EnhancedParticipants from './index';
+
+import type ServerDataOperator from '@database/operator/server_data_operator';
+import type {Database} from '@nozbe/watermelondb';
+
+jest.mock('./participants', () => ({
+ __esModule: true,
+ default: jest.fn(),
+}));
+jest.mocked(Participants).mockImplementation(
+ (props) => React.createElement('Participants', {testID: 'participants', ...props}),
+);
+
+describe('EnhancedParticipants (withObservables and withDatabase)', () => {
+ let database: Database;
+ let operator: ServerDataOperator;
+ const serverUrl = 'https://server-url.com';
+
+ beforeEach(async () => {
+ await DatabaseManager.init([serverUrl]);
+ const serverDatabaseAndOperator = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
+ database = serverDatabaseAndOperator.database;
+ operator = serverDatabaseAndOperator.operator;
+ });
+
+ afterEach(async () => {
+ await DatabaseManager.destroyServerDatabase(serverUrl);
+ jest.clearAllMocks();
+ });
+
+ function getBaseProps(): ComponentProps {
+ return {
+ participantIds: ['user-1', 'user-2'],
+ location: 'Channel',
+ baseTextStyle: {},
+ };
+ }
+
+ it('should render correctly without data in the database', () => {
+ const props = getBaseProps();
+ const {getByTestId} = renderWithEverything(
+ ,
+ {database, serverUrl},
+ );
+
+ const component = getByTestId('participants');
+ expect(component).toBeTruthy();
+ expect(component).toHaveProp('users', []);
+ });
+
+ it('should pass users from database when they exist', async () => {
+ const user1 = TestHelper.fakeUser({id: 'user-1', username: 'user1'});
+ const user2 = TestHelper.fakeUser({id: 'user-2', username: 'user2'});
+
+ await operator.handleUsers({users: [user1, user2], prepareRecordsOnly: false});
+
+ const props = getBaseProps();
+ const {getByTestId} = renderWithEverything(
+ ,
+ {database, serverUrl},
+ );
+
+ const component = getByTestId('participants');
+ expect(component.props.users).toHaveLength(2);
+ expect(component.props.users[0].id).toBe(user1.id);
+ expect(component.props.users[1].id).toBe(user2.id);
+ });
+
+ it('should update when participant ids change', async () => {
+ const user1 = TestHelper.fakeUser({id: 'user-1', username: 'user1'});
+ const user2 = TestHelper.fakeUser({id: 'user-2', username: 'user2'});
+
+ await operator.handleUsers({users: [user1, user2], prepareRecordsOnly: false});
+
+ const props = getBaseProps();
+ const {getByTestId, rerender} = renderWithEverything(
+ ,
+ {database, serverUrl},
+ );
+
+ const component1 = getByTestId('participants');
+ expect(component1.props.users).toHaveLength(2);
+ expect(component1.props.users[0].id).toBe(user1.id);
+ expect(component1.props.users[1].id).toBe(user2.id);
+
+ props.participantIds = ['user-1'];
+ rerender();
+
+ const component2 = getByTestId('participants');
+ expect(component2.props.users).toHaveLength(1);
+ expect(component2.props.users[0].id).toBe(user1.id);
+ });
+});
+
diff --git a/app/products/playbooks/components/status_update_post/participants/participants.test.tsx b/app/products/playbooks/components/status_update_post/participants/participants.test.tsx
new file mode 100644
index 000000000..3d7a519d6
--- /dev/null
+++ b/app/products/playbooks/components/status_update_post/participants/participants.test.tsx
@@ -0,0 +1,250 @@
+// 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 {Platform} from 'react-native';
+
+import CompassIcon from '@components/compass_icon';
+import UsersList from '@components/user_avatars_stack/users_list';
+import DatabaseManager from '@database/manager';
+import {useIsTablet} from '@hooks/device';
+import {bottomSheet} from '@screens/navigation';
+import {renderWithEverything} from '@test/intl-test-helper';
+import TestHelper from '@test/test_helper';
+
+import Participants from './participants';
+
+import type {Database} from '@nozbe/watermelondb';
+import type {AvailableScreens} from '@typings/screens/navigation';
+
+jest.mock('@components/compass_icon', () => ({
+ __esModule: true,
+ default: jest.fn(),
+}));
+jest.mocked(CompassIcon).mockImplementation((props) => React.createElement('CompassIcon', {...props, testID: 'compass-icon'}) as any);
+
+jest.mock('@components/user_avatars_stack/users_list', () => ({
+ __esModule: true,
+ default: jest.fn(),
+}));
+jest.mocked(UsersList).mockImplementation((props) => React.createElement('UsersList', {...props, testID: 'users-list'}));
+
+jest.mock('@hooks/device', () => ({
+ useIsTablet: jest.fn(),
+}));
+
+describe('Participants', () => {
+ const location = 'Channel' as AvailableScreens;
+ const user1 = TestHelper.fakeUserModel({
+ id: 'user-1',
+ username: 'user1',
+ });
+ const user2 = TestHelper.fakeUserModel({
+ id: 'user-2',
+ username: 'user2',
+ });
+ const user3 = TestHelper.fakeUserModel({
+ id: 'user-3',
+ username: 'user3',
+ });
+
+ function getBaseProps(): ComponentProps {
+ return {
+ participantIds: ['user-1', 'user-2'],
+ users: [user1, user2],
+ location,
+ baseTextStyle: {},
+ };
+ }
+
+ let database: Database;
+ const serverUrl = 'server-url';
+
+ beforeEach(async () => {
+ jest.clearAllMocks();
+ jest.mocked(useIsTablet).mockReturnValue(false);
+ Platform.OS = 'ios';
+
+ await DatabaseManager.init([serverUrl]);
+ database = DatabaseManager.getServerDatabaseAndOperator(serverUrl).database;
+ });
+
+ afterEach(async () => {
+ await DatabaseManager.destroyServerDatabase(serverUrl);
+ });
+
+ it('renders correctly with participants', () => {
+ const props = getBaseProps();
+ const {getByTestId, getByText} = renderWithEverything(, {database});
+
+ const icon = getByTestId('compass-icon');
+ expect(icon).toBeTruthy();
+ expect(icon).toHaveProp('name', 'account-multiple-outline');
+
+ expect(getByText('2 participants')).toBeVisible();
+ });
+
+ it('displays singular form for one participant', () => {
+ const props = getBaseProps();
+ props.participantIds = ['user-1'];
+ props.users = [user1];
+ const {getByText} = renderWithEverything(, {database, serverUrl});
+
+ expect(getByText('1 participant')).toBeVisible();
+ });
+
+ it('opens bottom sheet when pressed', async () => {
+ const props = getBaseProps();
+ const {root} = renderWithEverything(, {database, serverUrl});
+
+ await act(async () => {
+ fireEvent.press(root);
+ });
+
+ await waitFor(() => {
+ expect(bottomSheet).toHaveBeenCalledWith(expect.objectContaining({
+ closeButtonId: 'close-set-user-status',
+ renderContent: expect.any(Function),
+ initialSnapIndex: 1,
+ snapPoints: expect.any(Array),
+ title: 'Run Participants',
+ theme: expect.any(Object),
+ }));
+ });
+ });
+
+ it('renders UsersList in bottom sheet content', async () => {
+ const props = getBaseProps();
+ const {root} = renderWithEverything(, {database, serverUrl});
+
+ await act(async () => {
+ fireEvent.press(root);
+ });
+
+ await waitFor(() => {
+ const args = jest.mocked(bottomSheet).mock.calls[0][0];
+ const Content = args.renderContent;
+ const {getByTestId} = renderWithEverything(, {database, serverUrl});
+ const usersList = getByTestId('users-list');
+ expect(usersList).toBeTruthy();
+ expect(usersList.props.users).toEqual([user1, user2]);
+ expect(usersList.props.location).toBe(location);
+ });
+ });
+
+ it('shows list header on non-tablet devices', async () => {
+ jest.mocked(useIsTablet).mockReturnValue(false);
+ const props = getBaseProps();
+ const {root} = renderWithEverything(, {database, serverUrl});
+
+ await act(async () => {
+ fireEvent.press(root);
+ });
+
+ await waitFor(() => {
+ const args = jest.mocked(bottomSheet).mock.calls[0][0];
+ const Content = args.renderContent;
+ const {getByText} = renderWithEverything(, {database, serverUrl});
+ expect(getByText('Run Participants')).toBeTruthy();
+ });
+ });
+
+ it('hides list header on tablet devices', async () => {
+ jest.mocked(useIsTablet).mockReturnValue(true);
+ const props = getBaseProps();
+ const {root} = renderWithEverything(, {database, serverUrl});
+
+ await act(async () => {
+ fireEvent.press(root);
+ });
+
+ await waitFor(() => {
+ const args = jest.mocked(bottomSheet).mock.calls[0][0];
+ const Content = args.renderContent;
+ const {queryByText} = renderWithEverything(, {database, serverUrl});
+ expect(queryByText('Run Participants')).toBeNull();
+ });
+ });
+
+ it('calculates snap points correctly for few users', async () => {
+ const props = getBaseProps();
+ props.participantIds = ['user-1', 'user-2'];
+ props.users = [user1, user2];
+ const {root} = renderWithEverything(, {database, serverUrl});
+
+ await act(async () => {
+ fireEvent.press(root);
+ });
+
+ await waitFor(() => {
+ const args = jest.mocked(bottomSheet).mock.calls[0][0];
+ expect(args.snapPoints).toHaveLength(2);
+ expect(args.snapPoints[0]).toBe(1);
+ });
+ });
+
+ it('adds max height snap point when users exceed max displayed', async () => {
+ const users = [user1, user2, user3, TestHelper.fakeUserModel({id: 'user-4'}), TestHelper.fakeUserModel({id: 'user-5'}), TestHelper.fakeUserModel({id: 'user-6'})];
+ const props = getBaseProps();
+ props.participantIds = users.map((u) => u.id);
+ props.users = users;
+ const {root} = renderWithEverything(, {database, serverUrl});
+
+ await act(async () => {
+ fireEvent.press(root);
+ });
+
+ await waitFor(() => {
+ const args = jest.mocked(bottomSheet).mock.calls[0][0];
+ expect(args.snapPoints).toHaveLength(3);
+ expect(args.snapPoints[2]).toBe('80%');
+ });
+ });
+
+ it('adds android offset on Android platform', async () => {
+ Platform.OS = 'android';
+ const props = getBaseProps();
+ const {root} = renderWithEverything(, {database, serverUrl});
+
+ await act(async () => {
+ fireEvent.press(root);
+ });
+
+ await waitFor(() => {
+ const args = jest.mocked(bottomSheet).mock.calls[0][0];
+ expect(args.snapPoints).toEqual([1, 158]);
+ });
+ });
+
+ it('does not add android offset on iOS platform', async () => {
+ Platform.OS = 'ios';
+ const props = getBaseProps();
+ const {root} = renderWithEverything(, {database, serverUrl});
+
+ await act(async () => {
+ fireEvent.press(root);
+ });
+
+ await waitFor(() => {
+ const args = jest.mocked(bottomSheet).mock.calls[0][0];
+ expect(args.snapPoints).toEqual([1, 146]);
+ });
+ });
+
+ it('uses usePreventDoubleTap hook', async () => {
+ const props = getBaseProps();
+ const {root} = renderWithEverything(, {database, serverUrl});
+
+ await act(async () => {
+ fireEvent.press(root);
+ });
+
+ await waitFor(() => {
+ fireEvent.press(root);
+ });
+
+ expect(bottomSheet).toHaveBeenCalledTimes(1);
+ });
+});
+
diff --git a/app/products/playbooks/screens/navigation.test.ts b/app/products/playbooks/screens/navigation.test.ts
index 27958d0b1..4b2900316 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} from './navigation';
+import {goToPlaybookRuns, goToPlaybookRun, goToParticipantPlaybooks, goToPlaybookRunWithChannelSwitch, goToEditCommand, goToSelectUser, goToSelectDate, goToPostUpdate, goToSelectPlaybook, goToStartARun} from './navigation';
jest.mock('@screens/navigation', () => ({
goToScreen: jest.fn(),
@@ -68,6 +68,26 @@ describe('Playbooks Navigation', () => {
{},
);
});
+
+ it('should navigate to single playbook run screen with playbookRun parameter', async () => {
+ const playbookRunId = 'playbook-run-id-1';
+ const playbookRun = TestHelper.fakePlaybookRun({
+ id: playbookRunId,
+ });
+
+ await goToPlaybookRun(mockIntl, playbookRunId, playbookRun);
+
+ expect(mockIntl.formatMessage).toHaveBeenCalledWith({
+ id: 'playbooks.playbook_run.title',
+ defaultMessage: 'Playbook run',
+ });
+ expect(goToScreen).toHaveBeenCalledWith(
+ Screens.PLAYBOOK_RUN,
+ 'Playbook run',
+ {playbookRunId, playbookRun},
+ {},
+ );
+ });
});
describe('goToEditCommand', () => {
@@ -100,6 +120,35 @@ describe('Playbooks Navigation', () => {
},
);
});
+
+ it('should navigate to edit command screen with null command', async () => {
+ const channelId = 'channel-id-1';
+ const updateCommand = jest.fn();
+
+ await goToEditCommand(mockIntl, Preferences.THEMES.denim, 'Run 1', null, channelId, updateCommand);
+
+ expect(mockIntl.formatMessage).toHaveBeenCalledWith({
+ id: 'playbooks.edit_command.title',
+ defaultMessage: 'Slash command',
+ });
+ expect(goToScreen).toHaveBeenCalledWith(
+ Screens.PLAYBOOK_EDIT_COMMAND,
+ 'Slash command',
+ {
+ savedCommand: null,
+ updateCommand,
+ channelId,
+ },
+ {
+ topBar: {
+ subtitle: {
+ text: 'Run 1',
+ color: changeOpacity(Preferences.THEMES.denim.sidebarHeaderTextColor, 0.72),
+ },
+ },
+ },
+ );
+ });
});
describe('goToSelectUser', () => {
@@ -131,6 +180,34 @@ describe('Playbooks Navigation', () => {
},
);
});
+
+ it('should navigate to select user screen without handleRemove parameter', async () => {
+ const title = 'Select User';
+ const participantIds = ['user1', 'user2', 'user3'];
+ const selected = 'user2';
+ const handleSelect = jest.fn();
+
+ await goToSelectUser(Preferences.THEMES.denim, 'Run 1', title, participantIds, selected, handleSelect);
+
+ expect(goToScreen).toHaveBeenCalledWith(
+ Screens.PLAYBOOK_SELECT_USER,
+ title,
+ {
+ participantIds,
+ selected,
+ handleSelect,
+ handleRemove: undefined,
+ },
+ {
+ topBar: {
+ subtitle: {
+ text: 'Run 1',
+ color: changeOpacity(Preferences.THEMES.denim.sidebarHeaderTextColor, 0.72),
+ },
+ },
+ },
+ );
+ });
});
describe('goToSelectDate', () => {
@@ -239,5 +316,171 @@ describe('Playbooks Navigation', () => {
{},
);
});
+
+ it('should handle PlaybookRunModel', async () => {
+ const serverUrl = 'https://test.server.com';
+ const mockPlaybookRunModel = TestHelper.fakePlaybookRunModel({
+ id: 'run-id-1',
+ channelId: 'channel-id-1',
+ teamId: 'team-id-1',
+ });
+
+ jest.mocked(joinIfNeededAndSwitchToChannel).mockResolvedValue({});
+
+ await goToPlaybookRunWithChannelSwitch(mockIntl, serverUrl, mockPlaybookRunModel);
+
+ expect(joinIfNeededAndSwitchToChannel).toHaveBeenCalledWith(
+ serverUrl,
+ {id: mockPlaybookRunModel.channelId},
+ {id: mockPlaybookRunModel.teamId},
+ expect.any(Function),
+ mockIntl,
+ );
+
+ expect(mockIntl.formatMessage).toHaveBeenCalledWith({
+ id: 'playbooks.playbook_run.title',
+ defaultMessage: 'Playbook run',
+ });
+ expect(goToScreen).toHaveBeenCalledWith(
+ Screens.PLAYBOOK_RUN,
+ 'Playbook run',
+ {playbookRunId: mockPlaybookRunModel.id},
+ {},
+ );
+ });
+
+ it('should not navigate when channel switch fails', async () => {
+ const serverUrl = 'https://test.server.com';
+ const mockPlaybookRun = TestHelper.fakePlaybookRun({
+ id: 'run-id-1',
+ channel_id: 'channel-id-1',
+ team_id: 'team-id-1',
+ });
+
+ jest.mocked(joinIfNeededAndSwitchToChannel).mockResolvedValue({error: 'Channel switch failed'});
+
+ await goToPlaybookRunWithChannelSwitch(mockIntl, serverUrl, mockPlaybookRun);
+
+ expect(joinIfNeededAndSwitchToChannel).toHaveBeenCalledWith(
+ serverUrl,
+ {id: mockPlaybookRun.channel_id},
+ {id: mockPlaybookRun.team_id},
+ expect.any(Function),
+ mockIntl,
+ );
+
+ expect(mockIntl.formatMessage).not.toHaveBeenCalled();
+ expect(goToScreen).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('goToPostUpdate', () => {
+ it('should navigate to post update screen with correct parameters', async () => {
+ const playbookRunId = 'playbook-run-id-1';
+
+ await goToPostUpdate(mockIntl, playbookRunId);
+
+ expect(mockIntl.formatMessage).toHaveBeenCalledWith({
+ id: 'playbooks.post_update.title',
+ defaultMessage: 'Post update',
+ });
+ expect(goToScreen).toHaveBeenCalledWith(
+ Screens.PLAYBOOK_POST_UPDATE,
+ 'Post update',
+ {playbookRunId},
+ {},
+ );
+ });
+ });
+
+ describe('goToSelectPlaybook', () => {
+ it('should navigate to select playbook screen with channelId', async () => {
+ const channelId = 'channel-id-1';
+
+ await goToSelectPlaybook(mockIntl, Preferences.THEMES.denim, channelId);
+
+ expect(goToScreen).toHaveBeenCalledWith(
+ Screens.PLAYBOOKS_SELECT_PLAYBOOK,
+ 'Start a run',
+ {channelId},
+ {
+ topBar: {
+ subtitle: {
+ text: 'Select a playbook',
+ color: changeOpacity(Preferences.THEMES.denim.sidebarText, 0.72),
+ },
+ },
+ },
+ );
+ });
+
+ it('should navigate to select playbook screen without channelId', async () => {
+ await goToSelectPlaybook(mockIntl, Preferences.THEMES.denim);
+
+ expect(goToScreen).toHaveBeenCalledWith(
+ Screens.PLAYBOOKS_SELECT_PLAYBOOK,
+ 'Start a run',
+ {channelId: undefined},
+ {
+ topBar: {
+ subtitle: {
+ text: 'Select a playbook',
+ color: changeOpacity(Preferences.THEMES.denim.sidebarText, 0.72),
+ },
+ },
+ },
+ );
+ });
+ });
+
+ describe('goToStartARun', () => {
+ it('should navigate to start a run screen with correct parameters', async () => {
+ const playbook = TestHelper.fakePlaybook({
+ id: 'playbook-id-1',
+ title: 'Test Playbook',
+ });
+ const onRunCreated = jest.fn();
+ const channelId = 'channel-id-1';
+
+ await goToStartARun(mockIntl, Preferences.THEMES.denim, playbook, onRunCreated, channelId);
+
+ expect(goToScreen).toHaveBeenCalledWith(
+ Screens.PLAYBOOKS_START_A_RUN,
+ 'Start a run',
+ {playbook, onRunCreated, channelId},
+ {
+ topBar: {
+ subtitle: {
+ text: playbook.title,
+ color: changeOpacity(Preferences.THEMES.denim.sidebarText, 0.72),
+ },
+ },
+ },
+ );
+ });
+
+ it('should navigate to start a run screen without channelId', async () => {
+ const playbook = TestHelper.fakePlaybook({
+ id: 'playbook-id-1',
+ title: 'Test Playbook',
+ });
+ const onRunCreated = jest.fn();
+
+ await goToStartARun(mockIntl, Preferences.THEMES.denim, playbook, onRunCreated);
+
+ expect(goToScreen).toHaveBeenCalledWith(
+ Screens.PLAYBOOKS_START_A_RUN,
+ 'Start a run',
+ {playbook, onRunCreated, channelId: undefined},
+ {
+ topBar: {
+ subtitle: {
+ text: playbook.title,
+ color: changeOpacity(Preferences.THEMES.denim.sidebarText, 0.72),
+ },
+ },
+ },
+ );
+ });
});
});
diff --git a/app/products/playbooks/screens/participant_playbooks/participant_playbooks.test.tsx b/app/products/playbooks/screens/participant_playbooks/participant_playbooks.test.tsx
index 49c6ece6c..b41590dbe 100644
--- a/app/products/playbooks/screens/participant_playbooks/participant_playbooks.test.tsx
+++ b/app/products/playbooks/screens/participant_playbooks/participant_playbooks.test.tsx
@@ -7,7 +7,9 @@ import {of as of$} from 'rxjs';
import DatabaseManager from '@database/manager';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import {fetchPlaybookRunsPageForParticipant} from '@playbooks/actions/remote/runs';
-import {fireEvent, renderWithEverything, waitFor, waitForElementToBeRemoved} from '@test/intl-test-helper';
+import RunList from '@playbooks/components/run_list';
+import {popTopScreen} from '@screens/navigation';
+import {renderWithEverything, waitFor} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
import ParticipantPlaybooks from './participant_playbooks';
@@ -17,6 +19,18 @@ import type {Database} from '@nozbe/watermelondb';
jest.mock('@hooks/android_back_handler');
jest.mock('@playbooks/actions/remote/runs');
+jest.mock('@playbooks/components/run_list', () => ({
+ __esModule: true,
+ default: jest.fn(),
+}));
+jest.mocked(RunList).mockImplementation(
+ (props) => React.createElement('RunList', {testID: 'run_list', ...props}),
+);
+
+jest.mock('@hooks/utils', () => ({
+ usePreventDoubleTap: (fn: () => void) => fn,
+}));
+
type FetchPlaybookRunsPageForParticipantReturn = Awaited>;
describe('ParticipantPlaybooks', () => {
@@ -71,60 +85,71 @@ describe('ParticipantPlaybooks', () => {
});
jest.mocked(fetchPlaybookRunsPageForParticipant).mockReturnValue(pendingPromise);
- const {getByTestId, queryByTestId} = renderWithEverything(, {database, serverUrl});
+ const {getByTestId} = renderWithEverything(, {database, serverUrl});
- expect(getByTestId('loading')).toBeTruthy();
+ const runList = getByTestId('run_list');
+ expect(runList).toHaveProp('loading', true);
// Check that fetch was called
await waitFor(() => {
expect(fetchPlaybookRunsPageForParticipant).toHaveBeenCalledWith('server-url', 'test-user-id', 0);
});
- expect(getByTestId('loading')).toBeTruthy();
+ expect(runList).toHaveProp('loading', true);
// Resolve the promise
act(() => {
resolvePromise!({runs: [], hasMore: false});
});
- await waitForElementToBeRemoved(() => queryByTestId('loading'));
+ await waitFor(() => {
+ expect(runList).toHaveProp('loading', false);
+ });
});
- it('renders empty state when no runs available', async () => {
+ it('pass down empty runs when no runs available', async () => {
const props = getBaseProps();
jest.mocked(fetchPlaybookRunsPageForParticipant).mockResolvedValue({runs: [], hasMore: false});
- const {queryByTestId, getByText} = renderWithEverything(, {database, serverUrl});
+ const {getByTestId} = renderWithEverything(, {database, serverUrl});
- await waitForElementToBeRemoved(() => queryByTestId('loading'));
-
- // Should show empty state instead of loading
- expect(getByText('No in progress runs')).toBeVisible();
+ await waitFor(() => {
+ const runList = getByTestId('run_list');
+ expect(runList).toHaveProp('loading', false);
+ expect(runList).toHaveProp('inProgressRuns', []);
+ expect(runList).toHaveProp('finishedRuns', []);
+ });
});
- it('renders error state when fetch fails', async () => {
+ it('pass down empty runs when initial fetch fails and stop showing the show more button', async () => {
const props = getBaseProps();
jest.mocked(fetchPlaybookRunsPageForParticipant).mockResolvedValue({error: 'Network error'});
- const {queryByTestId, getByText} = renderWithEverything(, {database, serverUrl});
+ const {getByTestId} = renderWithEverything(, {database, serverUrl});
- await waitForElementToBeRemoved(() => queryByTestId('loading'));
-
- // Should show empty state instead of loading
- expect(getByText('No in progress runs')).toBeVisible();
+ await waitFor(() => {
+ const runList = getByTestId('run_list');
+ expect(runList).toHaveProp('loading', false);
+ expect(runList).toHaveProp('inProgressRuns', []);
+ expect(runList).toHaveProp('finishedRuns', []);
+ expect(runList.props.showMoreButton('finished')).toBe(false);
+ expect(runList.props.showMoreButton('in-progress')).toBe(false);
+ });
});
- it('renders playbook runs when data is loaded', async () => {
+ it('pass down in progress and finished runs when data is loaded', async () => {
const props = getBaseProps();
const runs = getMockRuns(1, 1);
jest.mocked(fetchPlaybookRunsPageForParticipant).mockResolvedValue({runs, hasMore: false});
- const {queryByTestId, getByText} = renderWithEverything(, {database, serverUrl});
+ const {getByTestId} = renderWithEverything(, {database, serverUrl});
- await waitForElementToBeRemoved(() => queryByTestId('loading'));
-
- expect(getByText('Test Run 0')).toBeVisible();
- expect(queryByTestId('Test Run 1')).not.toBeVisible();
+ await waitFor(() => {
+ const runList = getByTestId('run_list');
+ expect(runList).toHaveProp('loading', false);
+ expect(runList).toHaveProp('inProgressRuns', [runs[0]]);
+ expect(runList).toHaveProp('finishedRuns', [runs[1]]);
+ });
});
it('does not fetch data when currentUserId is not provided', () => {
@@ -132,75 +157,127 @@ describe('ParticipantPlaybooks', () => {
props.currentUserId = '';
jest.mocked(fetchPlaybookRunsPageForParticipant).mockResolvedValue({runs: [], hasMore: false});
- renderWithEverything(, {database, serverUrl});
+ const {getByTestId} = renderWithEverything(, {database, serverUrl});
+
+ const runList = getByTestId('run_list');
+ expect(runList).toHaveProp('componentId', props.componentId);
+ expect(runList).toHaveProp('inProgressRuns', []);
+ expect(runList).toHaveProp('finishedRuns', []);
// Should not call the fetch function when no user ID
expect(fetchPlaybookRunsPageForParticipant).not.toHaveBeenCalled();
});
- it('handles Android back button', () => {
- const props = getBaseProps();
- const {popTopScreen} = require('@screens/navigation');
-
- renderWithEverything(, {database, serverUrl});
-
- expect(useAndroidHardwareBackHandler).toHaveBeenCalledWith(props.componentId, expect.any(Function));
-
- const closeHandler = jest.mocked(useAndroidHardwareBackHandler).mock.calls[0][1];
- closeHandler();
-
- expect(popTopScreen).toHaveBeenCalledWith(props.componentId);
- });
-
- it('handles pagination with hasMore=true', async () => {
+ it('shows the show more button when when we have more runs', async () => {
const props = getBaseProps();
const runs = getMockRuns(10, 0);
jest.mocked(fetchPlaybookRunsPageForParticipant).mockResolvedValue({runs, hasMore: true});
- const {queryByTestId, getByTestId} = renderWithEverything(, {database, serverUrl});
+ const {findByTestId} = renderWithEverything(, {database, serverUrl});
- await waitForElementToBeRemoved(() => queryByTestId('loading'));
+ const runList = await findByTestId('run_list');
+ expect(runList).toHaveProp('showMoreButton', expect.any(Function));
- expect(fetchPlaybookRunsPageForParticipant).toHaveBeenCalledWith('server-url', 'test-user-id', 0);
-
- // Should set hasMore state to false when the API response indicates no more data
- expect(fetchPlaybookRunsPageForParticipant).toHaveBeenCalledTimes(1);
-
- const list = getByTestId('runs.list');
-
- await act(async () => {
- fireEvent(list, 'onEndReached');
- });
-
- expect(fetchPlaybookRunsPageForParticipant).toHaveBeenCalledWith('server-url', 'test-user-id', 1);
- expect(fetchPlaybookRunsPageForParticipant).toHaveBeenCalledTimes(2);
+ const showMoreButton = runList.props.showMoreButton;
+ expect(showMoreButton('finished')).toBe(true);
+ expect(showMoreButton('in-progress')).toBe(true);
});
- it('handles pagination with hasMore=false', async () => {
+ it('does not show the show more button when we do not have more runs', async () => {
const props = getBaseProps();
const runs = getMockRuns(10, 0);
jest.mocked(fetchPlaybookRunsPageForParticipant).mockResolvedValue({runs, hasMore: false});
- const {queryByTestId, getByTestId} = renderWithEverything(, {database, serverUrl});
+ const {findByTestId} = renderWithEverything(, {database, serverUrl});
- await waitForElementToBeRemoved(() => queryByTestId('loading'));
+ const runList = await findByTestId('run_list');
+ const showMoreButton = runList.props.showMoreButton;
+ expect(showMoreButton('finished')).toBe(false);
+ expect(showMoreButton('in-progress')).toBe(false);
+ });
+
+ it('handles pagination after several calls to fetchMoreRuns', async () => {
+ const props = getBaseProps();
+ const runs = getMockRuns(10, 0);
+ jest.mocked(fetchPlaybookRunsPageForParticipant).mockResolvedValue({runs, hasMore: true});
+
+ const {findByTestId} = renderWithEverything(, {database, serverUrl});
expect(fetchPlaybookRunsPageForParticipant).toHaveBeenCalledWith('server-url', 'test-user-id', 0);
- // Should set hasMore state to false when the API response indicates no more data
- expect(fetchPlaybookRunsPageForParticipant).toHaveBeenCalledTimes(1);
-
- const list = getByTestId('runs.list');
-
- await act(async () => {
- fireEvent(list, 'onEndReached');
+ const runList = await findByTestId('run_list');
+ act(() => {
+ const fetchMoreRuns = runList.props.fetchMoreRuns;
+ fetchMoreRuns('in-progress');
});
- expect(fetchPlaybookRunsPageForParticipant).toHaveBeenCalledTimes(1);
+ await waitFor(() => {
+ expect(fetchPlaybookRunsPageForParticipant).toHaveBeenCalledWith('server-url', 'test-user-id', 1);
+ expect(runList.props.inProgressRuns).toHaveLength(20);
+ });
+
+ act(() => {
+ const fetchMoreRuns = runList.props.fetchMoreRuns;
+ fetchMoreRuns('in-progress');
+ });
+
+ await waitFor(() => {
+ expect(fetchPlaybookRunsPageForParticipant).toHaveBeenCalledWith('server-url', 'test-user-id', 2);
+ expect(runList.props.inProgressRuns).toHaveLength(30);
+ });
+ });
+
+ it('gracefully handles error after load', async () => {
+ const props = getBaseProps();
+ const runs = getMockRuns(10, 0);
+ jest.mocked(fetchPlaybookRunsPageForParticipant).mockResolvedValue({runs, hasMore: true});
+
+ const {findByTestId} = renderWithEverything(, {database, serverUrl});
+ expect(fetchPlaybookRunsPageForParticipant).toHaveBeenCalledWith('server-url', 'test-user-id', 0);
+
+ jest.mocked(fetchPlaybookRunsPageForParticipant).mockResolvedValue({error: 'Network error'});
+ const runList = await findByTestId('run_list');
+ act(() => {
+ const fetchMoreRuns = runList.props.fetchMoreRuns;
+ fetchMoreRuns('in-progress');
+ });
+
+ await waitFor(() => {
+ expect(runList.props.fetching).toBe(false);
+ expect(fetchPlaybookRunsPageForParticipant).toHaveBeenCalledWith('server-url', 'test-user-id', 1);
+ expect(runList.props.inProgressRuns).toHaveLength(10);
+ });
+ });
+
+ it('set fetching while fetching more runs', async () => {
+ const props = getBaseProps();
+ const runs = getMockRuns(10, 0);
+ jest.mocked(fetchPlaybookRunsPageForParticipant).mockResolvedValue({runs, hasMore: true});
+
+ const {findByTestId} = renderWithEverything(, {database, serverUrl});
+ expect(fetchPlaybookRunsPageForParticipant).toHaveBeenCalledWith('server-url', 'test-user-id', 0);
+
+ const runList = await findByTestId('run_list');
+ act(() => {
+ const fetchMoreRuns = runList.props.fetchMoreRuns;
+ fetchMoreRuns('in-progress');
+ });
+
+ // Should set fetching as soon as we call fetchMoreRuns
+ await waitFor(() => {
+ expect(runList).toHaveProp('fetching', true);
+ });
+
+ // Wait for the results to be set
+ await waitFor(() => {
+ expect(runList.props.inProgressRuns).toHaveLength(20);
+ });
+
+ // Should set fetching to false when the results are set
+ expect(runList).toHaveProp('fetching', false);
});
it('should show cached warning when API fails and database has data', async () => {
- // Mock API failure
jest.mocked(fetchPlaybookRunsPageForParticipant).mockResolvedValue({
error: 'Network error',
});
@@ -212,11 +289,27 @@ describe('ParticipantPlaybooks', () => {
const props = getBaseProps();
props.cachedPlaybookRuns = [mockDatabaseRun];
- const {getByText, queryByTestId} = renderWithEverything(, {database, serverUrl});
+ const {getByTestId} = renderWithEverything(, {database, serverUrl});
- await waitForElementToBeRemoved(() => queryByTestId('loading'));
+ await waitFor(() => {
+ const runList = getByTestId('run_list');
+ expect(runList).toHaveProp('loading', false);
+ expect(runList).toHaveProp('showCachedWarning', true);
+ expect(runList).toHaveProp('inProgressRuns', [mockDatabaseRun]);
+ });
+ });
- expect(getByText(/Showing cached data only/)).toBeVisible();
+ it('should not show cached warning if there are no cached runs', async () => {
+ const props = getBaseProps();
+ jest.mocked(fetchPlaybookRunsPageForParticipant).mockResolvedValue({error: 'Network error'});
+
+ const {getByTestId} = renderWithEverything(, {database, serverUrl});
+
+ await waitFor(() => {
+ const runList = getByTestId('run_list');
+ expect(runList).toHaveProp('loading', false);
+ expect(runList).toHaveProp('showCachedWarning', false);
+ });
});
it('should not show cached warning when API succeeds', async () => {
@@ -228,10 +321,48 @@ describe('ParticipantPlaybooks', () => {
hasMore: false,
});
- const {queryByText, queryByTestId} = renderWithEverything(, {database, serverUrl});
+ const {getByTestId} = renderWithEverything(, {database, serverUrl});
- await waitForElementToBeRemoved(() => queryByTestId('loading'));
+ await waitFor(() => {
+ const runList = getByTestId('run_list');
+ expect(runList).toHaveProp('loading', false);
+ expect(runList).toHaveProp('showCachedWarning', false);
+ });
+ });
- expect(queryByText(/Showing cached data only/)).not.toBeVisible();
+ it('handles Android back button', async () => {
+ const props = getBaseProps();
+
+ renderWithEverything(, {database, serverUrl});
+
+ expect(useAndroidHardwareBackHandler).toHaveBeenCalledWith(props.componentId, expect.any(Function));
+
+ const closeHandler = jest.mocked(useAndroidHardwareBackHandler).mock.calls[0][1];
+ await act(async () => {
+ closeHandler();
+ });
+
+ expect(popTopScreen).toHaveBeenCalledWith(props.componentId);
+ });
+
+ it('should not load more if there are no more runs', async () => {
+ const props = getBaseProps();
+ const runs = getMockRuns(10, 0);
+ jest.mocked(fetchPlaybookRunsPageForParticipant).mockResolvedValue({runs, hasMore: false});
+
+ const {findByTestId} = renderWithEverything(, {database, serverUrl});
+ expect(fetchPlaybookRunsPageForParticipant).toHaveBeenCalledWith('server-url', 'test-user-id', 0);
+
+ const runList = await findByTestId('run_list');
+ act(() => {
+ const fetchMoreRuns = runList.props.fetchMoreRuns;
+ fetchMoreRuns('in-progress');
+ });
+
+ await waitFor(() => {
+ expect(fetchPlaybookRunsPageForParticipant).toHaveBeenCalledTimes(1);
+ expect(runList.props.fetching).toBe(false);
+ expect(runList.props.inProgressRuns).toHaveLength(10);
+ });
});
});
diff --git a/app/products/playbooks/screens/participant_playbooks/participant_playbooks.tsx b/app/products/playbooks/screens/participant_playbooks/participant_playbooks.tsx
index bb6b5c6c8..43e704f20 100644
--- a/app/products/playbooks/screens/participant_playbooks/participant_playbooks.tsx
+++ b/app/products/playbooks/screens/participant_playbooks/participant_playbooks.tsx
@@ -1,26 +1,14 @@
// 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, useEffect, useMemo, useState} from 'react';
-import {defineMessages, useIntl} from 'react-intl';
-import {View} from 'react-native';
-import Loading from '@components/loading';
-import SectionNotice from '@components/section_notice';
import {useServerUrl} from '@context/server';
-import {useTheme} from '@context/theme';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
-import useTabs, {type TabDefinition} from '@hooks/use_tabs';
-import Tabs from '@hooks/use_tabs/tabs';
import {fetchPlaybookRunsPageForParticipant} from '@playbooks/actions/remote/runs';
-import PlaybookScreens from '@playbooks/constants/screens';
+import RunList from '@playbooks/components/run_list';
import {isRunFinished} from '@playbooks/utils/run';
import {popTopScreen} from '@screens/navigation';
-import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
-
-import EmptyState from '../playbooks_runs/empty_state';
-import PlaybookCard, {CARD_HEIGHT} from '../playbooks_runs/playbook_card';
import type PlaybookRunModel from '@playbooks/types/database/models/playbook_run';
import type {AvailableScreens} from '@typings/screens/navigation';
@@ -31,79 +19,16 @@ type Props = {
cachedPlaybookRuns: PlaybookRunModel[];
};
-type TabsNames = 'in-progress' | 'finished';
-
-const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => ({
- container: {
- padding: 20,
- },
- tabContainer: {
- borderBottomWidth: 1,
- borderBottomColor: changeOpacity(theme.centerChannelColor, 0.12),
- },
- warningText: {
- color: theme.centerChannelColor,
- fontSize: 14,
- lineHeight: 20,
- },
- warningContainer: {
- paddingTop: 8,
- paddingHorizontal: 16,
- },
-}));
-
-const messages = defineMessages({
- cachedWarningTitle: {
- id: 'playbooks.participant_playbooks.cached_warning_title',
- defaultMessage: 'Cannot reach the server',
- },
- cachedWarningMessage: {
- id: 'playbooks.participant_playbooks.cached_warning_message',
- defaultMessage: 'Showing cached data only. Some playbook runs or updates may be missing from this list.',
- },
- tabInProgress: {
- id: 'playbooks.participant_playbooks.tab_in_progress',
- defaultMessage: 'In Progress',
- },
- tabFinished: {
- id: 'playbooks.participant_playbooks.tab_finished',
- defaultMessage: 'Finished',
- },
-});
-
-const tabs: Array> = [
- {
- id: 'in-progress',
- name: messages.tabInProgress,
- },
- {
- id: 'finished',
- name: messages.tabFinished,
- },
-];
-
-const itemSeparatorStyle = {
- height: 12,
-};
-
-const ItemSeparator = () => {
- return ;
-};
-
const ParticipantPlaybooks = ({
currentUserId,
componentId,
cachedPlaybookRuns,
}: Props) => {
- const intl = useIntl();
const serverUrl = useServerUrl();
- const theme = useTheme();
- const styles = getStyleFromTheme(theme);
const [participantRuns, setParticipantRuns] = useState>([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
- const [hasError, setHasError] = useState(false);
const [hasMore, setHasMore] = useState(false);
const [currentPage, setCurrentPage] = useState(0);
const [showCachedWarning, setShowCachedWarning] = useState(false);
@@ -122,30 +47,27 @@ const ParticipantPlaybooks = ({
if (append) {
setLoadingMore(true);
} else {
- setLoading(true);
- setHasError(false);
setShowCachedWarning(false);
+ setLoading(true);
}
- const result = await fetchPlaybookRunsPageForParticipant(serverUrl, currentUserId, page);
+ const {runs = [], hasMore: hasMoreFromResult = false, error} = await fetchPlaybookRunsPageForParticipant(serverUrl, currentUserId, page);
- if (result.error) {
+ if (error) {
// Fallback to database cache only for the first page
if (page === 0 && cachedPlaybookRuns.length > 0) {
setParticipantRuns(cachedPlaybookRuns);
setHasMore(false);
setShowCachedWarning(true);
- } else {
- setHasError(true);
}
} else {
- const newRuns = result.runs || [];
+ const newRuns = runs;
if (append) {
setParticipantRuns((prev) => [...prev, ...newRuns]);
} else {
setParticipantRuns(newRuns);
}
- setHasMore(result.hasMore || false);
+ setHasMore(hasMoreFromResult);
setCurrentPage(page);
}
@@ -169,6 +91,10 @@ const ParticipantPlaybooks = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
+ const showMoreButton = useCallback(() => {
+ return hasMore;
+ }, [hasMore]);
+
const [inProgressRuns, finishedRuns] = useMemo(() => {
const inProgress: Array = [];
const finished: Array = [];
@@ -184,58 +110,17 @@ const ParticipantPlaybooks = ({
return [inProgress, finished] as const;
}, [participantRuns]);
- const [activeTab, tabsProps] = useTabs('in-progress', tabs);
-
- const data = activeTab === 'in-progress' ? inProgressRuns : finishedRuns;
- const isEmpty = data.length === 0;
-
- const renderItem: ListRenderItem = useCallback(({item}) => {
- return (
-
- );
- }, []);
-
- let content;
- if (loading) {
- content = ;
- } else if (hasError || isEmpty) {
- content = ();
- } else {
- content = (
- : undefined}
- testID={'runs.list'}
- />
- );
- }
-
return (
- <>
-
-
-
- {showCachedWarning && (
-
-
-
- )}
- {content}
- >
+
);
};
diff --git a/app/products/playbooks/screens/playbook_run/playbook_run.test.tsx b/app/products/playbooks/screens/playbook_run/playbook_run.test.tsx
index 9e31d126e..56b60a717 100644
--- a/app/products/playbooks/screens/playbook_run/playbook_run.test.tsx
+++ b/app/products/playbooks/screens/playbook_run/playbook_run.test.tsx
@@ -9,8 +9,9 @@ import UserAvatarsStack from '@components/user_avatars_stack';
import {General} from '@constants';
import {useServerUrl} from '@context/server';
import DatabaseManager from '@database/manager';
+import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import {finishRun, setOwner} from '@playbooks/actions/remote/runs';
-import {openUserProfileModal} from '@screens/navigation';
+import {openUserProfileModal, popTopScreen} from '@screens/navigation';
import {fireEvent, renderWithEverything, waitFor} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
import {showPlaybookErrorSnackbar} from '@utils/snack_bar';
@@ -72,6 +73,8 @@ jest.mock('@playbooks/actions/remote/runs', () => ({
finishRun: jest.fn(),
}));
+jest.mock('@hooks/android_back_handler');
+
describe('PlaybookRun', () => {
let database: Database;
@@ -374,4 +377,16 @@ describe('PlaybookRun', () => {
expect(showPlaybookErrorSnackbar).toHaveBeenCalled();
});
});
+
+ it('does android hardware back handler', () => {
+ const props = getBaseProps();
+ renderWithEverything(, {database});
+
+ expect(useAndroidHardwareBackHandler).toHaveBeenCalledWith(props.componentId, expect.any(Function));
+
+ const closeHandler = jest.mocked(useAndroidHardwareBackHandler).mock.calls[0][1];
+ closeHandler();
+
+ expect(popTopScreen).toHaveBeenCalled();
+ });
});
diff --git a/app/products/playbooks/screens/playbook_run/playbook_run.tsx b/app/products/playbooks/screens/playbook_run/playbook_run.tsx
index ba4ea50c6..7a6d41c27 100644
--- a/app/products/playbooks/screens/playbook_run/playbook_run.tsx
+++ b/app/products/playbooks/screens/playbook_run/playbook_run.tsx
@@ -179,7 +179,6 @@ export default function PlaybookRun({
useAndroidHardwareBackHandler(componentId, () => {
popTopScreen();
- return true;
});
const isParticipant = participants.some((p) => p.id === currentUserId) || owner?.id === currentUserId;
diff --git a/app/products/playbooks/screens/playbook_run/status_update_indicator.test.tsx b/app/products/playbooks/screens/playbook_run/status_update_indicator.test.tsx
index e5f9715d0..9f73b2a7d 100644
--- a/app/products/playbooks/screens/playbook_run/status_update_indicator.test.tsx
+++ b/app/products/playbooks/screens/playbook_run/status_update_indicator.test.tsx
@@ -6,9 +6,11 @@ import {StyleSheet} from 'react-native';
import CompassIcon from '@components/compass_icon';
import {Preferences} from '@constants';
-import {renderWithIntl} from '@test/intl-test-helper';
+import {act, fireEvent, renderWithIntl} from '@test/intl-test-helper';
import {changeOpacity} from '@utils/theme';
+import {goToPostUpdate} from '../navigation';
+
import StatusUpdateIndicator from './status_update_indicator';
jest.mock('@components/compass_icon', () => ({
@@ -19,6 +21,10 @@ jest.mocked(CompassIcon).mockImplementation(
(props) => React.createElement('CompassIcon', {testID: 'compass-icon', ...props}) as any, // override the type since it is expecting a class component
);
+jest.mock('../navigation', () => ({
+ goToPostUpdate: jest.fn(),
+}));
+
describe('StatusUpdateIndicator', () => {
const futureTimestamp = Date.now() + 86400000; // 24 hours from now
const pastTimestamp = Date.now() - 86400000; // 24 hours ago
@@ -80,4 +86,40 @@ describe('StatusUpdateIndicator', () => {
expect(icon.props.name).toBe('flag-checkered');
expect(StyleSheet.flatten(icon.props.style)).toEqual(expect.objectContaining({color: changeOpacity(Preferences.THEMES.denim.centerChannelColor, 0.72)}));
});
+
+ it('navigates to post update on press', () => {
+ const {getByText} = renderWithIntl(
+ ,
+ );
+
+ const button = getByText('Post update');
+ act(() => {
+ fireEvent.press(button);
+ });
+
+ expect(goToPostUpdate).toHaveBeenCalledWith(expect.anything(), 'run-id');
+ });
+
+ it('does not navigate to post update if run is finished', () => {
+ const {getByText} = renderWithIntl(
+ ,
+ );
+
+ const button = getByText('Post update');
+ act(() => {
+ fireEvent.press(button);
+ });
+
+ expect(goToPostUpdate).not.toHaveBeenCalled();
+ });
});
diff --git a/app/products/playbooks/screens/playbook_run/status_update_indicator.tsx b/app/products/playbooks/screens/playbook_run/status_update_indicator.tsx
index d0c4ab7ff..b9bd27b75 100644
--- a/app/products/playbooks/screens/playbook_run/status_update_indicator.tsx
+++ b/app/products/playbooks/screens/playbook_run/status_update_indicator.tsx
@@ -120,12 +120,8 @@ const StatusUpdateIndicator = ({
}, [styles.icon, styles.overdueText, isFinished, timestamp]);
const onUpdatePress = useCallback(async () => {
- if (readOnly) {
- return;
- }
-
await goToPostUpdate(intl, playbookRunId);
- }, [intl, playbookRunId, readOnly]);
+ }, [intl, playbookRunId]);
const icon = isFinished ? 'flag-checkered' : 'clock-outline';
return (
diff --git a/app/products/playbooks/screens/playbooks_runs/playbook_runs.test.tsx b/app/products/playbooks/screens/playbooks_runs/playbook_runs.test.tsx
index abdb7cc7d..c43be0a6c 100644
--- a/app/products/playbooks/screens/playbooks_runs/playbook_runs.test.tsx
+++ b/app/products/playbooks/screens/playbooks_runs/playbook_runs.test.tsx
@@ -1,21 +1,34 @@
// 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 {act, waitFor} from '@testing-library/react-native';
import React from 'react';
+import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
+import {fetchFinishedRunsForChannel} from '@playbooks/actions/remote/runs';
+import RunList from '@playbooks/components/run_list';
+import {popTopScreen} from '@screens/navigation';
import {renderWithIntl} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
-import EmptyState from './empty_state';
-import PlaybookCard from './playbook_card';
import PlaybookRuns from './playbook_runs';
-jest.mock('./empty_state');
-jest.mocked(EmptyState).mockImplementation((props) => React.createElement('EmptyState', {...props, testID: 'empty-state'}));
+jest.mock('@playbooks/components/run_list');
+jest.mocked(RunList).mockImplementation((props) => React.createElement('RunList', {...props, testID: 'run-list'}));
-jest.mock('./playbook_card');
-jest.mocked(PlaybookCard).mockImplementation((props) => React.createElement('PlaybookCard', {...props, testID: 'playbook-card'}));
+jest.mock('@playbooks/screens/navigation', () => ({
+ goToSelectPlaybook: jest.fn(),
+}));
+
+jest.mock('@playbooks/actions/remote/runs', () => ({
+ fetchFinishedRunsForChannel: jest.fn(),
+}));
+
+jest.mock('@context/server', () => ({
+ useServerUrl: jest.fn(() => 'server-url'),
+}));
+
+jest.mock('@hooks/android_back_handler');
describe('PlaybookRuns', () => {
const inProgressRun = TestHelper.fakePlaybookRunModel({
@@ -28,7 +41,7 @@ describe('PlaybookRuns', () => {
currentStatus: 'Finished',
});
- it('shows empty state when no runs in selected tab', () => {
+ it('provides empty lists when no runs', () => {
const {getByTestId} = renderWithIntl(
{
/>,
);
- const emptyState = getByTestId('empty-state');
- expect(emptyState).toBeTruthy();
- expect(emptyState.props.tab).toBe('finished');
+ const runList = getByTestId('run-list');
+
+ expect(runList.props.inProgressRuns).toHaveLength(0);
+ expect(runList.props.finishedRuns).toHaveLength(0);
});
- it('switches between tabs correctly', async () => {
- const {getByText, getByTestId} = renderWithIntl(
+ it('sets the correct runs to the correct props', () => {
+ const {getByTestId} = renderWithIntl(
,
);
-
- let card = getByTestId('playbook-card');
- expect(card.props.run).toBe(inProgressRun);
-
- // Switch to finished tab
- act(() => {
- fireEvent.press(getByText('Finished'));
- });
-
- await waitFor(() => {
- card = getByTestId('playbook-card');
- expect(card.props.run).toBe(finishedRun);
- });
- });
-
- it('should default to finished tab if no in-progress runs', () => {
- const {getByText, getByTestId, queryByTestId} = renderWithIntl(
- ,
- );
-
- const card = getByTestId('playbook-card');
- expect(card.props.run).toBe(finishedRun);
-
- // Switch to in-progress tab
- act(() => {
- fireEvent.press(getByText('In Progress'));
- });
-
- expect(queryByTestId('playbook-card')).toBeNull();
- expect(getByTestId('empty-state')).toBeTruthy();
+ const runList = getByTestId('run-list');
+ expect(runList.props.inProgressRuns).toHaveLength(1);
+ expect(runList.props.finishedRuns).toHaveLength(1);
+ expect(runList.props.inProgressRuns[0]).toBe(inProgressRun);
+ expect(runList.props.finishedRuns[0]).toBe(finishedRun);
});
it('shows the show more button only on finished tabs', () => {
- const {getByText, queryByText} = renderWithIntl(
+ const {getByTestId} = renderWithIntl(
{
/>,
);
- expect(queryByText('Show More')).toBeNull();
+ const runLists = getByTestId('run-list');
+ expect(runLists.props.showMoreButton('in-progress')).toBe(false);
+ expect(runLists.props.showMoreButton('finished')).toBe(true);
+ });
+ it('hides the show more button when there are no more runs', async () => {
+ const {getByTestId} = renderWithIntl(
+ ,
+ );
+
+ const runList = getByTestId('run-list');
+ expect(runList.props.showMoreButton('finished')).toBe(true);
+
+ jest.mocked(fetchFinishedRunsForChannel).mockResolvedValue({runs: [], has_more: false});
act(() => {
- fireEvent.press(getByText('Finished'));
+ runList.props.fetchMoreRuns('finished');
});
- expect(getByText('Show More')).toBeTruthy();
+ await waitFor(() => {
+ expect(runList.props.showMoreButton('finished')).toBe(false);
+ });
+ });
+
+ it('calls fetchMoreRuns when show more button is pressed and paginates', async () => {
+ const moreRuns1 = TestHelper.fakePlaybookRun({id: 'more-run-1'});
+ jest.mocked(fetchFinishedRunsForChannel).mockResolvedValue({runs: [moreRuns1], has_more: true});
+ const {getByTestId} = renderWithIntl(
+ ,
+ );
+
+ const runList = getByTestId('run-list');
+ let fetchMoreRuns = runList.props.fetchMoreRuns;
+
+ act(() => {
+ fetchMoreRuns('finished');
+ });
+
+ await waitFor(() => {
+ expect(runList.props.fetching).toBe(true);
+ });
+
+ await waitFor(() => {
+ expect(fetchFinishedRunsForChannel).toHaveBeenCalledWith('server-url', 'channel-id-1', 0);
+ expect(runList.props.finishedRuns).toHaveLength(1);
+ expect(runList.props.finishedRuns[0]).toBe(moreRuns1);
+ });
+
+ expect(runList.props.fetching).toBe(false);
+
+ fetchMoreRuns = runList.props.fetchMoreRuns;
+ const moreRuns2 = TestHelper.fakePlaybookRun({id: 'more-run-2'});
+ jest.mocked(fetchFinishedRunsForChannel).mockResolvedValue({runs: [moreRuns2], has_more: true});
+ act(() => {
+ fetchMoreRuns('finished');
+ });
+
+ await waitFor(() => {
+ expect(runList.props.fetching).toBe(true);
+ });
+
+ await waitFor(() => {
+ expect(fetchFinishedRunsForChannel).toHaveBeenCalledWith('server-url', 'channel-id-1', 1);
+ expect(runList.props.finishedRuns).toHaveLength(2);
+ expect(runList.props.finishedRuns[0]).toBe(moreRuns1);
+ expect(runList.props.finishedRuns[1]).toBe(moreRuns2);
+ });
+
+ expect(runList.props.fetching).toBe(false);
+ });
+
+ it('does not fetch more runs while fetching', async () => {
+ const {getByTestId} = renderWithIntl(
+ ,
+ );
+
+ const runList = getByTestId('run-list');
+ let resolvePromise: (() => void) | undefined;
+ jest.mocked(fetchFinishedRunsForChannel).mockImplementation(() => {
+ const promise = new Promise<{runs: PlaybookRun[]; has_more: boolean}>((resolve) => {
+ resolvePromise = () => resolve({runs: [], has_more: true});
+ });
+ return promise;
+ });
+
+ act(() => {
+ runList.props.fetchMoreRuns('finished');
+ });
+
+ await waitFor(() => {
+ expect(runList.props.fetching).toBe(true);
+ });
+ expect(fetchFinishedRunsForChannel).toHaveBeenCalledTimes(1);
+ expect(fetchFinishedRunsForChannel).toHaveBeenCalledWith('server-url', 'channel-id-1', 0);
+
+ act(() => {
+ runList.props.fetchMoreRuns('finished');
+ });
+
+ await waitFor(() => {
+ expect(fetchFinishedRunsForChannel).toHaveBeenCalledTimes(1);
+ });
+ resolvePromise?.();
+ });
+
+ it('an error on fetch more runs hides the show more button', async () => {
+ const {getByTestId} = renderWithIntl(
+ ,
+ );
+
+ const runList = getByTestId('run-list');
+ expect(runList.props.showMoreButton('finished')).toBe(true);
+
+ jest.mocked(fetchFinishedRunsForChannel).mockResolvedValue({error: 'error'});
+ act(() => {
+ runList.props.fetchMoreRuns('finished');
+ });
+
+ await waitFor(() => {
+ expect(runList.props.showMoreButton('finished')).toBe(false);
+ });
+ });
+
+ it('does not fetch more runs on in-progress tab', async () => {
+ const {getByTestId} = renderWithIntl(
+ ,
+ );
+
+ const runList = getByTestId('run-list');
+ act(() => {
+ runList.props.fetchMoreRuns('in-progress');
+ });
+
+ expect(fetchFinishedRunsForChannel).not.toHaveBeenCalled();
+ });
+
+ it('handles Android back button', async () => {
+ renderWithIntl(
+ ,
+ );
+
+ expect(useAndroidHardwareBackHandler).toHaveBeenCalledWith('PlaybookRuns', expect.any(Function));
+
+ const closeHandler = jest.mocked(useAndroidHardwareBackHandler).mock.calls[0][1];
+ await act(async () => {
+ closeHandler();
+ });
+
+ expect(popTopScreen).toHaveBeenCalledWith('PlaybookRuns');
+ });
+
+ it('passes down the channel id to the run list', () => {
+ const {getByTestId} = renderWithIntl(
+ ,
+ );
+ const runList = getByTestId('run-list');
+ expect(runList).toHaveProp('channelId', 'channel-id-1');
});
});
diff --git a/app/products/playbooks/screens/playbooks_runs/playbook_runs.tsx b/app/products/playbooks/screens/playbooks_runs/playbook_runs.tsx
index 2b9aff049..605bbbd6f 100644
--- a/app/products/playbooks/screens/playbooks_runs/playbook_runs.tsx
+++ b/app/products/playbooks/screens/playbooks_runs/playbook_runs.tsx
@@ -1,26 +1,14 @@
// 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, useState} from 'react';
-import {defineMessage, useIntl} from 'react-intl';
-import {StyleSheet, View} from 'react-native';
+import React, {useCallback, useMemo, useRef, useState} from 'react';
-import Button from '@components/button';
-import {Screens} from '@constants';
-import {useTheme} from '@context/theme';
+import {useServerUrl} from '@context/server';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
-import useTabs, {type TabDefinition} from '@hooks/use_tabs';
-import Tabs from '@hooks/use_tabs/tabs';
+import {fetchFinishedRunsForChannel} from '@playbooks/actions/remote/runs';
+import RunList, {type RunListTabsNames} from '@playbooks/components/run_list';
import {isRunFinished} from '@playbooks/utils/run';
import {popTopScreen} from '@screens/navigation';
-import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
-
-import {goToSelectPlaybook} from '../navigation';
-
-import EmptyState from './empty_state';
-import PlaybookCard, {CARD_HEIGHT} from './playbook_card';
-import ShowMoreButton from './show_more_button';
import type PlaybookRunModel from '@playbooks/types/database/models/playbook_run';
import type {AvailableScreens} from '@typings/screens/navigation';
@@ -31,61 +19,18 @@ type Props = {
componentId: AvailableScreens;
};
-type TabsNames = 'in-progress' | 'finished';
-
-const itemSeparatorStyle = StyleSheet.create({
- itemSeparator: {
- height: 12,
- },
-});
-const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => ({
- container: {
- padding: 20,
- },
- tabContainer: {
- borderBottomWidth: 1,
- borderBottomColor: changeOpacity(theme.centerChannelColor, 0.12),
- },
- startANewRunButtonContainer: {
- padding: 20,
- },
-}));
-
-const ItemSeparator = () => {
- return ;
-};
-
-const tabs: Array> = [
- {
- id: 'in-progress',
- name: defineMessage({
- id: 'playbook.runs.in-progress',
- defaultMessage: 'In Progress',
- }),
- },
- {
- id: 'finished',
- name: defineMessage({
- id: 'playbook.runs.finished',
- defaultMessage: 'Finished',
- }),
- },
-];
-
const PlaybookRuns = ({
channelId,
allRuns,
componentId,
}: Props) => {
- const intl = useIntl();
- const theme = useTheme();
- const styles = getStyleFromTheme(theme);
+ const serverUrl = useServerUrl();
- const [fetchedFinishedRuns, setFetchedFinishedRuns] = useState([]);
+ const [fetching, setFetching] = useState(false);
+ const [hasMore, setHasMore] = useState(true);
+ const page = useRef(0);
- const pushNewFinishedRuns = useCallback((runs: PlaybookRun[]) => {
- setFetchedFinishedRuns((prev) => [...prev, ...runs]);
- }, []);
+ const [remoteFinishedRuns, setRemoteFinishedRuns] = useState([]);
const exit = useCallback(() => {
popTopScreen(componentId);
@@ -93,7 +38,7 @@ const PlaybookRuns = ({
useAndroidHardwareBackHandler(componentId, exit);
- const [inProgressRuns, finishedRuns] = useMemo(() => {
+ const [inProgressRuns, localFinishedRuns] = useMemo(() => {
const inProgress: PlaybookRunModel[] = [];
const finished: PlaybookRunModel[] = [];
@@ -108,74 +53,38 @@ const PlaybookRuns = ({
return [inProgress, finished] as const;
}, [allRuns]);
- const initialTab: TabsNames = inProgressRuns.length ? 'in-progress' : 'finished';
- const [activeTab, tabsProps] = useTabs(initialTab, tabs);
+ const showMoreButton = useCallback((tab: RunListTabsNames) => {
+ return tab === 'finished' && hasMore;
+ }, [hasMore]);
- let data: Array = inProgressRuns;
- if (activeTab === 'finished') {
- if (fetchedFinishedRuns.length) {
- data = fetchedFinishedRuns;
- } else {
- data = finishedRuns;
+ const fetchMoreFinishedRuns = useCallback(async (tab: RunListTabsNames) => {
+ if (fetching || tab !== 'finished') {
+ return;
}
- }
-
- const isEmpty = data.length === 0;
-
- const footerComponent = useMemo(() => (
-
- ), [channelId, pushNewFinishedRuns, activeTab]);
-
- const renderItem: ListRenderItem = useCallback(({item}) => {
- return (
-
- );
- }, []);
-
- const startANewRun = useCallback(() => {
- goToSelectPlaybook(intl, theme, channelId);
- }, [intl, theme, channelId]);
-
- let content = ();
- if (!isEmpty) {
- content = (
- <>
-
-
-
-
- >
- );
- }
+ setFetching(true);
+ const {runs, has_more = false, error} = await fetchFinishedRunsForChannel(serverUrl, channelId, page.current);
+ setFetching(false);
+ if (error) {
+ setHasMore(false);
+ return;
+ }
+ setHasMore(has_more);
+ page.current++;
+ if (runs?.length) {
+ setRemoteFinishedRuns((prev) => [...prev, ...runs]);
+ }
+ }, [channelId, fetching, serverUrl]);
return (
- <>
-
-
-
- {content}
- >
+
);
};
diff --git a/app/products/playbooks/screens/playbooks_runs/show_more_button.test.tsx b/app/products/playbooks/screens/playbooks_runs/show_more_button.test.tsx
deleted file mode 100644
index 6b0f56e5c..000000000
--- a/app/products/playbooks/screens/playbooks_runs/show_more_button.test.tsx
+++ /dev/null
@@ -1,206 +0,0 @@
-// 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 {fetchFinishedRunsForChannel} from '@playbooks/actions/remote/runs';
-import {renderWithIntl} from '@test/intl-test-helper';
-import TestHelper from '@test/test_helper';
-
-import ShowMoreButton from './show_more_button';
-
-jest.mock('@playbooks/actions/remote/runs');
-jest.mock('@context/server', () => ({
- useServerUrl: jest.fn(() => 'https://test.server.com'),
-}));
-
-describe('ShowMoreButton', () => {
- const mockFetchFinishedRunsForChannel = jest.mocked(fetchFinishedRunsForChannel);
-
- function getBaseProps(): ComponentProps {
- return {
- channelId: 'test-channel-id',
- addMoreRuns: jest.fn(),
- visible: true,
- };
- }
-
- beforeEach(() => {
- jest.clearAllMocks();
- });
-
- it('renders correctly when hasMore is true', () => {
- const props = getBaseProps();
- const {getByText} = renderWithIntl();
-
- const button = getByText('Show More');
- expect(button).toBeTruthy();
- });
-
- it('does not render when hasMore is false', async () => {
- const props = getBaseProps();
- mockFetchFinishedRunsForChannel.mockResolvedValueOnce({
- runs: [],
- has_more: false,
- });
-
- const {getByText, queryByText} = renderWithIntl();
-
- // Initially renders the button
- expect(getByText('Show More')).toBeTruthy();
-
- // Click the button to trigger the fetch
- await act(async () => {
- fireEvent.press(getByText('Show More'));
- });
-
- // Wait for the component to update and hide
- await waitFor(() => {
- expect(queryByText('Show More')).toBeNull();
- });
- });
-
- it('calls fetchFinishedRunsForChannel with correct parameters when button is pressed', async () => {
- const props = getBaseProps();
- mockFetchFinishedRunsForChannel.mockResolvedValueOnce({
- runs: [],
- has_more: true,
- });
-
- const {getByText} = renderWithIntl();
-
- await act(async () => {
- fireEvent.press(getByText('Show More'));
- });
-
- expect(mockFetchFinishedRunsForChannel).toHaveBeenCalledWith(
- 'https://test.server.com',
- 'test-channel-id',
- 0,
- );
- });
-
- it('increments page number on subsequent calls', async () => {
- const props = getBaseProps();
- mockFetchFinishedRunsForChannel.
- mockResolvedValueOnce({
- runs: [],
- has_more: true,
- }).
- mockResolvedValueOnce({
- runs: [],
- has_more: true,
- });
-
- const {getByText} = renderWithIntl();
-
- // First click
- await act(async () => {
- fireEvent.press(getByText('Show More'));
- });
-
- expect(mockFetchFinishedRunsForChannel).toHaveBeenCalledWith(
- 'https://test.server.com',
- 'test-channel-id',
- 0,
- );
-
- // Wait for DELAY before second click to simulate the double tap prevention
- await act(async () => {
- await new Promise((resolve) => setTimeout(resolve, 800)); // DELAY is 750ms
- fireEvent.press(getByText('Show More'));
- });
-
- expect(mockFetchFinishedRunsForChannel).toHaveBeenCalledWith(
- 'https://test.server.com',
- 'test-channel-id',
- 1,
- );
- });
-
- it('calls addMoreRuns when runs are returned', async () => {
- const props = getBaseProps();
- const mockRuns = [
- TestHelper.fakePlaybookRun({id: 'run-1'}),
- TestHelper.fakePlaybookRun({id: 'run-2'}),
- ];
- mockFetchFinishedRunsForChannel.mockResolvedValueOnce({
- runs: mockRuns,
- has_more: true,
- });
-
- const {getByText} = renderWithIntl();
-
- await act(async () => {
- fireEvent.press(getByText('Show More'));
- });
-
- waitFor(() => {
- expect(props.addMoreRuns).toHaveBeenCalledWith(mockRuns);
- });
- });
-
- it('does not call addMoreRuns when no runs are returned', async () => {
- const props = getBaseProps();
- mockFetchFinishedRunsForChannel.mockResolvedValueOnce({
- runs: [],
- has_more: true,
- });
-
- const {getByText} = renderWithIntl();
-
- await act(async () => {
- fireEvent.press(getByText('Show More'));
- });
-
- waitFor(() => {
- expect(props.addMoreRuns).not.toHaveBeenCalled();
- });
- });
-
- it('sets hasMore to false when API returns an error', async () => {
- const props = getBaseProps();
- mockFetchFinishedRunsForChannel.mockResolvedValueOnce({
- error: new Error('API Error'),
- });
-
- const {getByText, queryByText} = renderWithIntl();
-
- await act(async () => {
- fireEvent.press(getByText('Show More'));
- });
-
- waitFor(() => {
- expect(queryByText('Show More')).toBeNull();
- });
- });
-
- it('updates runs and maintains hasMore when API returns runs with has_more true', async () => {
- const props = getBaseProps();
- const mockRuns = [TestHelper.fakePlaybookRun({id: 'run-1'})];
- mockFetchFinishedRunsForChannel.mockResolvedValueOnce({
- runs: mockRuns,
- has_more: true,
- });
-
- const {getByText} = renderWithIntl();
-
- await act(async () => {
- fireEvent.press(getByText('Show More'));
- });
-
- waitFor(() => {
- expect(props.addMoreRuns).toHaveBeenCalledWith(mockRuns);
- expect(getByText('Show More')).toBeTruthy(); // Button should still be visible
- });
- });
-
- it('does not render when visible is false', () => {
- const props = getBaseProps();
- props.visible = false;
-
- const {queryByText} = renderWithIntl();
- expect(queryByText('Show More')).toBeNull();
- });
-});
diff --git a/app/products/playbooks/screens/playbooks_runs/show_more_button.tsx b/app/products/playbooks/screens/playbooks_runs/show_more_button.tsx
deleted file mode 100644
index d557dda7b..000000000
--- a/app/products/playbooks/screens/playbooks_runs/show_more_button.tsx
+++ /dev/null
@@ -1,74 +0,0 @@
-// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
-// See LICENSE.txt for license information.
-
-import {useCallback, useRef, useState} from 'react';
-import {useIntl} from 'react-intl';
-import {StyleSheet, View} from 'react-native';
-
-import Button from '@components/button';
-import {useServerUrl} from '@context/server';
-import {useTheme} from '@context/theme';
-import {usePreventDoubleTap} from '@hooks/utils';
-import {fetchFinishedRunsForChannel} from '@playbooks/actions/remote/runs';
-
-type Props = {
- channelId: string;
- addMoreRuns: (runs: PlaybookRun[]) => void;
- visible: boolean;
-};
-
-const styles = StyleSheet.create({
- container: {
- paddingTop: 16,
- },
-});
-
-const ShowMoreButton = ({
- channelId,
- addMoreRuns,
- visible,
-}: Props) => {
- const intl = useIntl();
- const theme = useTheme();
- const serverUrl = useServerUrl();
-
- const [fetching, setFetching] = useState(false);
- const [hasMore, setHasMore] = useState(true);
- const page = useRef(0);
-
- const fetchFinishedRuns = usePreventDoubleTap(useCallback(async () => {
- if (fetching) {
- return;
- }
- setFetching(true);
- const {runs, has_more, error} = await fetchFinishedRunsForChannel(serverUrl, channelId, page.current);
- setFetching(false);
- if (error) {
- setHasMore(false);
- return;
- }
- setHasMore(has_more ?? false);
- page.current++;
- if (runs?.length) {
- addMoreRuns(runs);
- }
- }, [channelId, fetching, serverUrl, addMoreRuns]));
-
- if (!hasMore || !visible) {
- return null;
- }
-
- return (
-
-
-
- );
-};
-
-export default ShowMoreButton;
diff --git a/app/products/playbooks/screens/post_update/index.test.tsx b/app/products/playbooks/screens/post_update/index.test.tsx
new file mode 100644
index 000000000..b0c51d7e8
--- /dev/null
+++ b/app/products/playbooks/screens/post_update/index.test.tsx
@@ -0,0 +1,263 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React, {type ComponentProps} from 'react';
+
+import {SYSTEM_IDENTIFIERS} from '@constants/database';
+import DatabaseManager from '@database/manager';
+import {getPlaybookChecklistItemById} from '@playbooks/database/queries/item';
+import {act, renderWithEverything, waitFor} from '@test/intl-test-helper';
+import TestHelper from '@test/test_helper';
+
+import PostUpdateComponent from './post_update';
+
+import PostUpdate from './';
+
+import type ServerDataOperator from '@database/operator/server_data_operator';
+import type {Database} from '@nozbe/watermelondb';
+
+jest.mock('./post_update');
+jest.mocked(PostUpdateComponent).mockImplementation(
+ (props) => React.createElement('PostUpdate', {testID: 'post-update', ...props}),
+);
+
+const serverUrl = 'server-url';
+
+describe('PostUpdate', () => {
+ const playbookRunId = 'run-id';
+ const runName = 'Test Run';
+ const channelId = 'channel-id';
+ const teamId = 'team-id';
+ const userId = 'user-id';
+
+ const baseRun = TestHelper.fakePlaybookRun({
+ id: playbookRunId,
+ name: runName,
+ channel_id: channelId,
+ checklists: [
+ TestHelper.fakePlaybookChecklist(playbookRunId, {
+ id: 'checklist-1',
+ items: [
+
+ // Outstanding items (state: '' or 'in_progress')
+ TestHelper.fakePlaybookChecklistItem(playbookRunId, {
+ id: 'item-1',
+ state: '',
+ }),
+ TestHelper.fakePlaybookChecklistItem(playbookRunId, {
+ id: 'item-2',
+ state: 'in_progress',
+ }),
+
+ // Closed item (not outstanding)
+ TestHelper.fakePlaybookChecklistItem(playbookRunId, {
+ id: 'item-3',
+ state: 'closed',
+ }),
+ ],
+ }),
+ TestHelper.fakePlaybookChecklist(playbookRunId, {
+ id: 'checklist-2',
+ items: [
+
+ // Outstanding item
+ TestHelper.fakePlaybookChecklistItem(playbookRunId, {
+ id: 'item-4',
+ state: 'in_progress',
+ }),
+
+ // Skipped item (not outstanding)
+ TestHelper.fakePlaybookChecklistItem(playbookRunId, {
+ id: 'item-5',
+ state: 'skipped',
+ }),
+ ],
+ }),
+ ],
+ items_order: ['checklist-1', 'checklist-2'],
+ });
+
+ let database: Database;
+ let operator: ServerDataOperator;
+
+ beforeEach(async () => {
+ await DatabaseManager.init([serverUrl]);
+ const serverDatabaseAndOperator = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
+ database = serverDatabaseAndOperator.database;
+ operator = serverDatabaseAndOperator.operator;
+ });
+
+ afterEach(() => {
+ DatabaseManager.destroyServerDatabase(serverUrl);
+ });
+
+ describe('with database data', () => {
+ function getBaseProps(): ComponentProps {
+ return {
+ playbookRunId,
+ componentId: 'PlaybookPostUpdate',
+ };
+ }
+
+ it('should render correctly with no data', () => {
+ const props = getBaseProps();
+ const {getByTestId} = renderWithEverything(, {database});
+
+ const postUpdate = getByTestId('post-update');
+ expect(postUpdate).toHaveProp('runName', '');
+ expect(postUpdate).toHaveProp('userId', '');
+ expect(postUpdate).toHaveProp('channelId', undefined);
+ expect(postUpdate).toHaveProp('teamId', '');
+ expect(postUpdate).toHaveProp('outstanding', 0);
+ });
+
+ it('should render correctly with playbook run data', async () => {
+ await operator.handlePlaybookRun({
+ prepareRecordsOnly: false,
+ runs: [baseRun],
+ processChildren: true,
+ });
+
+ await operator.handleSystem({
+ systems: [{
+ id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID,
+ value: userId,
+ }],
+ prepareRecordsOnly: false,
+ });
+
+ await operator.handleSystem({
+ systems: [{
+ id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID,
+ value: teamId,
+ }],
+ prepareRecordsOnly: false,
+ });
+
+ const props = getBaseProps();
+ const {getByTestId} = renderWithEverything(, {database});
+
+ await waitFor(() => {
+ const postUpdate = getByTestId('post-update');
+ expect(postUpdate).toHaveProp('runName', runName);
+ expect(postUpdate).toHaveProp('userId', userId);
+ expect(postUpdate).toHaveProp('channelId', channelId);
+ expect(postUpdate).toHaveProp('teamId', teamId);
+ expect(postUpdate).toHaveProp('outstanding', 3); // item-1, item-2, item-4 are outstanding
+ });
+ });
+
+ it('should calculate outstanding count correctly', async () => {
+ const runWithMixedStates = TestHelper.fakePlaybookRun({
+ id: playbookRunId,
+ name: runName,
+ channel_id: channelId,
+ checklists: [
+ TestHelper.fakePlaybookChecklist(playbookRunId, {
+ id: 'checklist-1',
+ items: [
+ TestHelper.fakePlaybookChecklistItem(playbookRunId, {
+ id: 'item-1',
+ state: '',
+ }),
+ TestHelper.fakePlaybookChecklistItem(playbookRunId, {
+ id: 'item-2',
+ state: 'in_progress',
+ }),
+ TestHelper.fakePlaybookChecklistItem(playbookRunId, {
+ id: 'item-3',
+ state: 'closed',
+ }),
+ TestHelper.fakePlaybookChecklistItem(playbookRunId, {
+ id: 'item-4',
+ state: 'skipped',
+ }),
+ ],
+ }),
+ ],
+ items_order: ['checklist-1'],
+ });
+
+ await operator.handlePlaybookRun({
+ prepareRecordsOnly: false,
+ runs: [runWithMixedStates],
+ processChildren: true,
+ });
+
+ await operator.handleSystem({
+ systems: [{
+ id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID,
+ value: userId,
+ }],
+ prepareRecordsOnly: false,
+ });
+
+ await operator.handleSystem({
+ systems: [{
+ id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID,
+ value: teamId,
+ }],
+ prepareRecordsOnly: false,
+ });
+
+ const props = getBaseProps();
+ const {getByTestId} = renderWithEverything(, {database});
+
+ await waitFor(() => {
+ const postUpdate = getByTestId('post-update');
+
+ // Only item-1 (state: '') and item-2 (state: 'in_progress') should be outstanding
+ expect(postUpdate).toHaveProp('outstanding', 2);
+ });
+ });
+
+ it('should update outstanding count when item states change', async () => {
+ await operator.handlePlaybookRun({
+ prepareRecordsOnly: false,
+ runs: [baseRun],
+ processChildren: true,
+ });
+
+ await operator.handleSystem({
+ systems: [{
+ id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID,
+ value: userId,
+ }],
+ prepareRecordsOnly: false,
+ });
+
+ await operator.handleSystem({
+ systems: [{
+ id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID,
+ value: teamId,
+ }],
+ prepareRecordsOnly: false,
+ });
+
+ const props = getBaseProps();
+ const {getByTestId} = renderWithEverything(, {database});
+
+ await waitFor(() => {
+ const postUpdate = getByTestId('post-update');
+ expect(postUpdate).toHaveProp('outstanding', 3);
+ });
+
+ // Update an outstanding item to closed
+ const itemToClose = await getPlaybookChecklistItemById(database, 'item-2');
+
+ act(() => {
+ database.write(async () => {
+ await itemToClose!.update((item) => {
+ item.state = 'closed';
+ });
+ });
+ });
+
+ await waitFor(() => {
+ const postUpdate = getByTestId('post-update');
+ expect(postUpdate).toHaveProp('outstanding', 2); // Should decrease from 3 to 2
+ });
+ });
+ });
+});
+
diff --git a/app/products/playbooks/screens/post_update/index.ts b/app/products/playbooks/screens/post_update/index.ts
index 24852afb1..bfda51834 100644
--- a/app/products/playbooks/screens/post_update/index.ts
+++ b/app/products/playbooks/screens/post_update/index.ts
@@ -18,7 +18,6 @@ import type {WithDatabaseArgs} from '@typings/database/database';
type OwnProps = {
playbookRunId: string;
- playbookRun?: PlaybookRun;
} & WithDatabaseArgs;
const getIds = (checklists: PlaybookChecklistModel[]) => {
diff --git a/app/products/playbooks/screens/post_update/post_update.test.tsx b/app/products/playbooks/screens/post_update/post_update.test.tsx
new file mode 100644
index 000000000..e8f868e6a
--- /dev/null
+++ b/app/products/playbooks/screens/post_update/post_update.test.tsx
@@ -0,0 +1,748 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+/* eslint-disable max-lines */
+
+import {act, waitFor} from '@testing-library/react-native';
+import React, {type ComponentProps} from 'react';
+import {Alert, Keyboard} from 'react-native';
+
+import {getPosts} from '@actions/local/post';
+import FloatingAutocompleteSelector from '@components/floating_input/floating_autocomplete_selector';
+import FloatingTextInput from '@components/floating_input/floating_text_input_label';
+import OptionItem from '@components/option_item';
+import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
+import useNavButtonPressed from '@hooks/navigation_button_pressed';
+import {fetchPlaybookRun, fetchPlaybookRunMetadata, postStatusUpdate} from '@playbooks/actions/remote/runs';
+import {popTopScreen, setButtons} from '@screens/navigation';
+import {renderWithIntlAndTheme} from '@test/intl-test-helper';
+import TestHelper from '@test/test_helper';
+
+import PostUpdate from './post_update';
+
+jest.mock('@components/floating_input/floating_text_input_label', () => ({
+ __esModule: true,
+ default: jest.fn(),
+}));
+jest.mocked(FloatingTextInput).mockImplementation((props) => React.createElement('FloatingTextInput', {testID: 'FloatingTextInput', ...props}));
+
+jest.mock('@components/floating_input/floating_autocomplete_selector', () => ({
+ __esModule: true,
+ default: jest.fn(),
+}));
+jest.mocked(FloatingAutocompleteSelector).mockImplementation((props) => React.createElement('FloatingAutocompleteSelector', props));
+
+jest.mock('@components/option_item', () => ({
+ __esModule: true,
+ default: jest.fn(),
+}));
+jest.mocked(OptionItem).mockImplementation((props) => React.createElement('OptionItem', {...props}));
+
+jest.mock('@actions/local/post', () => ({
+ getPosts: jest.fn(),
+}));
+
+jest.mock('@context/server', () => ({
+ useServerUrl: jest.fn(() => 'https://server-url.com'),
+}));
+
+jest.mock('@hooks/navigation_button_pressed', () => ({
+ __esModule: true,
+ default: jest.fn(),
+}));
+
+jest.mock('@hooks/android_back_handler', () => ({
+ __esModule: true,
+ default: jest.fn(),
+}));
+
+jest.mock('@playbooks/actions/remote/runs', () => ({
+ fetchPlaybookRun: jest.fn(),
+ fetchPlaybookRunMetadata: jest.fn(),
+ postStatusUpdate: jest.fn(),
+}));
+
+jest.mock('@utils/snack_bar', () => ({
+ showPlaybookErrorSnackbar: jest.fn(),
+}));
+
+jest.mock('react-native', () => {
+ const RN = jest.requireActual('react-native');
+ return {
+ ...RN,
+ Keyboard: {
+ dismiss: jest.fn(),
+ },
+ Alert: {
+ alert: jest.fn(),
+ },
+ };
+});
+
+function getLastCall(mock: jest.Mock): U {
+ const allCalls = mock.mock.calls;
+ return allCalls[allCalls.length - 1];
+}
+
+describe('PostUpdate', () => {
+ function getBaseProps(): ComponentProps {
+ return {
+ componentId: 'PlaybookPostUpdate',
+ playbookRunId: 'playbook-run-id',
+ runName: 'Test Run',
+ userId: 'user-id',
+ channelId: 'channel-id',
+ teamId: 'team-id',
+ outstanding: 0,
+ };
+ }
+
+ const mockMetadata: PlaybookRunMetadata = {
+ followers: ['follower-1', 'follower-2'],
+ channel_name: 'channel-name',
+ channel_display_name: 'channel-display-name',
+ team_name: 'team-name',
+ num_participants: 10,
+ total_posts: 100,
+ };
+
+ const mockRun = TestHelper.fakePlaybookRun({
+ id: 'playbook-run-id',
+ status_update_broadcast_channels_enabled: true,
+ broadcast_channel_ids: ['channel-1', 'channel-2'],
+ reminder_message_template: 'Default message template',
+ status_posts: [],
+ });
+
+ const mockPost = TestHelper.fakePostModel({
+ id: 'post-id',
+ message: 'Last status post message',
+ createAt: Date.now(),
+ });
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ jest.mocked(fetchPlaybookRunMetadata).mockResolvedValue({metadata: mockMetadata});
+ jest.mocked(fetchPlaybookRun).mockResolvedValue({run: mockRun});
+ jest.mocked(getPosts).mockResolvedValue([mockPost]);
+ jest.mocked(postStatusUpdate).mockResolvedValue({data: true});
+ });
+
+ it('should render loading state initially', () => {
+ const props = getBaseProps();
+ const {getByTestId} = renderWithIntlAndTheme();
+
+ expect(getByTestId('loader')).toBeTruthy();
+ });
+
+ it('should render correctly after loading', async () => {
+ const props = getBaseProps();
+ const {getByText, getByTestId} = renderWithIntlAndTheme();
+
+ await waitFor(() => {
+ expect(getByText(/This update for the run/)).toBeTruthy();
+ const input = getByTestId('FloatingTextInput');
+ expect(input).toHaveProp('value', 'Default message template');
+ expect(input).toHaveProp('label', 'Update message');
+ expect(input).toHaveProp('placeholder', 'Enter your update message');
+ expect(input).toHaveProp('multiline', true);
+
+ const selector = getByTestId('playbooks.post_update.selector');
+ expect(selector).toHaveProp('selected', '15_minutes');
+ expect(selector).toHaveProp('label', 'Timer for next update');
+ expect(selector).toHaveProp('options', expect.arrayContaining([
+ expect.objectContaining({value: '15_minutes', text: '15 minutes'}),
+ expect.objectContaining({value: '30_minutes', text: '30 minutes'}),
+ expect.objectContaining({value: '1_hour', text: '1 hour'}),
+ expect.objectContaining({value: '4_hours', text: '4 hours'}),
+ expect.objectContaining({value: '1_day', text: '1 day'}),
+ expect.objectContaining({value: '7_days', text: '7 days'}),
+ ]));
+ expect(selector.props.options).toHaveLength(6);
+
+ const toggle = getByTestId('playbooks.post_update.selector.also_mark_run_as_finished');
+ expect(toggle).toHaveProp('label', 'Also mark the run as finished');
+ expect(toggle).toHaveProp('selected', false);
+ expect(toggle).toHaveProp('type', 'toggle');
+ });
+ });
+
+ it('should load default message from reminder_message_template', async () => {
+ const props = getBaseProps();
+ const {getByTestId} = renderWithIntlAndTheme();
+
+ await waitFor(() => {
+ const input = getByTestId('FloatingTextInput');
+ expect(input).toHaveProp('value', 'Default message template');
+ });
+ });
+
+ it('should load default message from last status post when available', async () => {
+ const props = getBaseProps();
+ const runWithStatusPost = TestHelper.fakePlaybookRun({
+ id: 'playbook-run-id',
+ status_posts: [{
+ id: 'status-post-id',
+ create_at: Date.now(),
+ delete_at: 0,
+ }],
+ });
+
+ jest.mocked(fetchPlaybookRun).mockResolvedValue({run: runWithStatusPost});
+ jest.mocked(getPosts).mockResolvedValue([mockPost]);
+
+ const {getByTestId} = renderWithIntlAndTheme();
+
+ await waitFor(() => {
+ const input = getByTestId('FloatingTextInput');
+ expect(input.props.value).toBe('Last status post message');
+ });
+ });
+
+ it('should display correct intro message when no followers and no broadcast channels', async () => {
+ const props = getBaseProps();
+ jest.mocked(fetchPlaybookRunMetadata).mockResolvedValue({metadata: {followers: [], channel_name: '', channel_display_name: '', team_name: '', num_participants: 0, total_posts: 0}});
+ jest.mocked(fetchPlaybookRun).mockResolvedValue({
+ run: {
+ ...mockRun,
+ status_update_broadcast_channels_enabled: false,
+ },
+ });
+
+ const {getByText} = renderWithIntlAndTheme();
+
+ await waitFor(() => {
+ expect(getByText('This update will be saved to the overview page.')).toBeTruthy();
+ });
+ });
+
+ it('should display correct intro message with followers and broadcast channels', async () => {
+ const props = getBaseProps();
+ const {getByText} = renderWithIntlAndTheme();
+
+ await waitFor(() => {
+ expect(getByText('This update for the run Test Run will be broadcasted to 2 channels and 2 direct messages.')).toBeTruthy();
+ });
+ });
+
+ it('should update message when text input changes', async () => {
+ const props = getBaseProps();
+ const {getByTestId} = renderWithIntlAndTheme();
+
+ await waitFor(() => {
+ expect(getByTestId('playbooks.post_update.selector')).toBeTruthy();
+ });
+
+ const input = getByTestId('FloatingTextInput');
+ await act(async () => {
+ input.props.onChangeText('New update message');
+ });
+
+ await waitFor(() => {
+ const updatedInput = getByTestId('FloatingTextInput');
+ expect(updatedInput).toHaveProp('value', 'New update message');
+ });
+ });
+
+ it('should enable save button when message has content', async () => {
+ const props = getBaseProps();
+ renderWithIntlAndTheme();
+
+ await waitFor(() => {
+ expect(setButtons).toHaveBeenCalled();
+ });
+
+ const lastCall = jest.mocked(setButtons).mock.calls[jest.mocked(setButtons).mock.calls.length - 1];
+ const rightButton = lastCall[1]?.rightButtons?.[0];
+ expect(rightButton?.enabled).toBe(true);
+ });
+
+ it('should disable save button when message is empty', async () => {
+ const props = getBaseProps();
+ jest.mocked(fetchPlaybookRun).mockResolvedValue({
+ run: {
+ ...mockRun,
+ status_posts: [],
+ },
+ });
+
+ const {getByTestId} = renderWithIntlAndTheme();
+
+ await waitFor(() => {
+ const input = getByTestId('FloatingTextInput');
+ expect(input).toHaveProp('value', 'Default message template');
+ const lastCall = getLastCall(jest.mocked(setButtons));
+ const rightButton = lastCall[1]?.rightButtons?.[0];
+ expect(rightButton?.enabled).toBe(true);
+ });
+
+ const input = getByTestId('FloatingTextInput');
+ await act(async () => {
+ input.props.onChangeText('');
+ });
+
+ await waitFor(() => {
+ const lastCall = getLastCall(jest.mocked(setButtons));
+ const rightButton = lastCall[1]?.rightButtons?.[0];
+ expect(rightButton?.enabled).toBe(false);
+ });
+ });
+
+ it('should change next update value when selector changes', async () => {
+ const props = getBaseProps();
+ const {getByTestId} = renderWithIntlAndTheme();
+
+ await waitFor(() => {
+ expect(getByTestId('playbooks.post_update.selector')).toBeTruthy();
+ });
+
+ const selector = getByTestId('playbooks.post_update.selector');
+
+ await act(async () => {
+ selector.props.onSelected({value: '1_hour'});
+ });
+
+ await waitFor(() => {
+ const updatedSelector = getByTestId('playbooks.post_update.selector');
+ expect(updatedSelector).toHaveProp('selected', '1_hour');
+ });
+ });
+
+ it('should ignore multiselect value from selector', async () => {
+ const props = getBaseProps();
+ const {getByTestId} = renderWithIntlAndTheme();
+
+ await waitFor(() => {
+ const selector = getByTestId('playbooks.post_update.selector');
+ expect(selector).toBeTruthy();
+ expect(selector).toHaveProp('selected', '15_minutes');
+ });
+
+ const selector = getByTestId('playbooks.post_update.selector');
+ await act(async () => {
+ selector.props.onSelected([{value: '1_hour', text: '1 hour'}, {value: '30_minutes', text: '30 minutes'}]);
+ });
+
+ await waitFor(() => {
+ const updatedSelector = getByTestId('playbooks.post_update.selector');
+ expect(updatedSelector).toHaveProp('selected', '15_minutes');
+ });
+ });
+
+ it('should ignore empty value from selector', async () => {
+ const props = getBaseProps();
+ const {getByTestId} = renderWithIntlAndTheme();
+
+ await waitFor(() => {
+ const selector = getByTestId('playbooks.post_update.selector');
+ expect(selector).toBeTruthy();
+ expect(selector).toHaveProp('selected', '15_minutes');
+ });
+
+ const selector = getByTestId('playbooks.post_update.selector');
+ await act(async () => {
+ selector.props.onSelected();
+ });
+
+ await waitFor(() => {
+ const updatedSelector = getByTestId('playbooks.post_update.selector');
+ expect(updatedSelector).toHaveProp('selected', '15_minutes');
+ });
+ });
+
+ it('should toggle also mark run as finished option', async () => {
+ const props = getBaseProps();
+ const {getByTestId} = renderWithIntlAndTheme();
+
+ await waitFor(() => {
+ expect(getByTestId('playbooks.post_update.selector.also_mark_run_as_finished')).toBeTruthy();
+ });
+
+ const toggle = getByTestId('playbooks.post_update.selector.also_mark_run_as_finished');
+
+ expect(toggle).toHaveProp('selected', false);
+
+ await act(async () => {
+ toggle.props.action(true);
+ });
+
+ await waitFor(() => {
+ const updatedToggle = getByTestId('playbooks.post_update.selector.also_mark_run_as_finished');
+ expect(updatedToggle).toHaveProp('selected', true);
+ });
+ });
+
+ it('should post update without confirmation when alsoMarkRunAsFinished is false', async () => {
+ const props = getBaseProps();
+ renderWithIntlAndTheme();
+
+ await waitFor(() => {
+ expect(setButtons).toHaveBeenCalled();
+ });
+
+ const lastCall = getLastCall(jest.mocked(useNavButtonPressed));
+ const postHandler = lastCall[2];
+
+ await act(async () => {
+ postHandler();
+ });
+
+ await waitFor(() => {
+ expect(postStatusUpdate).toHaveBeenCalledWith(
+ 'https://server-url.com',
+ 'playbook-run-id',
+ {
+ message: 'Default message template',
+ reminder: 900, // 15 minutes in seconds
+ finishRun: false,
+ },
+ {
+ user_id: 'user-id',
+ channel_id: 'channel-id',
+ team_id: 'team-id',
+ },
+ );
+ expect(popTopScreen).toHaveBeenCalledWith('PlaybookPostUpdate');
+ expect(Keyboard.dismiss).toHaveBeenCalled();
+ });
+ });
+
+ it('should show confirmation alert when alsoMarkRunAsFinished is true', async () => {
+ const props = getBaseProps();
+ const {getByTestId} = renderWithIntlAndTheme();
+
+ await waitFor(() => {
+ expect(getByTestId('playbooks.post_update.selector.also_mark_run_as_finished')).toBeTruthy();
+ });
+
+ const toggle = getByTestId('playbooks.post_update.selector.also_mark_run_as_finished');
+
+ await act(async () => {
+ toggle.props.action(true);
+ });
+
+ const lastCall = getLastCall(jest.mocked(useNavButtonPressed));
+ const postHandler = lastCall[2];
+
+ await act(async () => {
+ postHandler();
+ });
+
+ await waitFor(() => {
+ expect(Alert.alert).toHaveBeenCalledWith(
+ 'Confirm finish run',
+ 'Are you sure you want to finish the run Test Run for all participants?',
+ expect.arrayContaining([
+ expect.objectContaining({
+ text: 'Cancel',
+ style: 'cancel',
+ }),
+ expect.objectContaining({
+ text: 'Finish run',
+ }),
+ ]),
+ );
+ });
+ });
+
+ it('should show confirmation alert with outstanding tasks message', async () => {
+ const props = {
+ ...getBaseProps(),
+ outstanding: 3,
+ };
+ const {getByTestId} = renderWithIntlAndTheme();
+
+ await waitFor(() => {
+ expect(getByTestId('playbooks.post_update.selector.also_mark_run_as_finished')).toBeTruthy();
+ });
+
+ const toggle = getByTestId('playbooks.post_update.selector.also_mark_run_as_finished');
+
+ await act(async () => {
+ toggle.props.action(true);
+ });
+
+ const lastCall = getLastCall(jest.mocked(useNavButtonPressed));
+ const postHandler = lastCall[2];
+ await act(async () => {
+ postHandler();
+ });
+
+ await waitFor(() => {
+ expect(Alert.alert).toHaveBeenCalledWith(
+ 'Confirm finish run',
+ 'There are 3 outstanding tasks. Are you sure you want to finish the run Test Run for all participants?',
+ expect.arrayContaining([
+ expect.objectContaining({
+ text: 'Cancel',
+ style: 'cancel',
+ }),
+ expect.objectContaining({
+ text: 'Finish run',
+ }),
+ ]),
+ );
+ });
+ });
+
+ it('should call postStatusUpdate with correct parameters when confirmed', async () => {
+ const props = getBaseProps();
+ const {getByTestId} = renderWithIntlAndTheme();
+
+ await waitFor(() => {
+ expect(getByTestId('playbooks.post_update.selector.also_mark_run_as_finished')).toBeTruthy();
+ });
+
+ const toggle = getByTestId('playbooks.post_update.selector.also_mark_run_as_finished');
+
+ await act(async () => {
+ toggle.props.action(true);
+ });
+
+ const lastCall = getLastCall(jest.mocked(useNavButtonPressed));
+ const postHandler = lastCall[2];
+ await act(async () => {
+ postHandler();
+ });
+
+ // Simulate alert confirm button press
+ const alertCall = jest.mocked(Alert.alert).mock.calls[0];
+ const confirmButton = alertCall[2]?.find((button) => button.text === 'Finish run');
+
+ await act(async () => {
+ confirmButton?.onPress?.();
+ });
+
+ await waitFor(() => {
+ expect(postStatusUpdate).toHaveBeenCalledWith(
+ 'https://server-url.com',
+ 'playbook-run-id',
+ {
+ message: 'Default message template',
+ reminder: 900,
+ finishRun: true,
+ },
+ {
+ user_id: 'user-id',
+ channel_id: 'channel-id',
+ team_id: 'team-id',
+ },
+ );
+ });
+ });
+
+ it('should handle postStatusUpdate error', async () => {
+ const props = getBaseProps();
+ const {showPlaybookErrorSnackbar} = require('@utils/snack_bar');
+ jest.mocked(postStatusUpdate).mockResolvedValue({error: new Error('Error posting update')});
+
+ renderWithIntlAndTheme();
+
+ const lastCall = getLastCall(jest.mocked(useNavButtonPressed));
+ const postHandler = lastCall[2];
+ await act(async () => {
+ postHandler();
+ });
+
+ await waitFor(() => {
+ expect(showPlaybookErrorSnackbar).toHaveBeenCalled();
+ });
+ });
+
+ it('should handle missing channelId gracefully', async () => {
+ const props = {
+ ...getBaseProps(),
+ channelId: undefined,
+ };
+ const {logDebug} = require('@utils/log');
+
+ renderWithIntlAndTheme();
+
+ await waitFor(() => {
+ const postHandler = jest.mocked(useNavButtonPressed).mock.calls.find(
+ (call) => call[0] === 'save-post-update',
+ )?.[2];
+
+ postHandler?.();
+ });
+
+ await waitFor(() => {
+ expect(logDebug).toHaveBeenCalledWith('cannot post status update without a channel id');
+ expect(postStatusUpdate).not.toHaveBeenCalled();
+ });
+ });
+
+ it('should handle fetchPlaybookRunMetadata error', async () => {
+ const props = getBaseProps();
+ const {logDebug} = require('@utils/log');
+ jest.mocked(fetchPlaybookRunMetadata).mockResolvedValue({error: 'Metadata fetch error'});
+
+ renderWithIntlAndTheme();
+
+ await waitFor(() => {
+ expect(logDebug).toHaveBeenCalledWith('error on fetchPlaybookRunMetadata', 'Metadata fetch error');
+ });
+ });
+
+ it('should handle fetchPlaybookRun error', async () => {
+ const props = getBaseProps();
+ const {logDebug} = require('@utils/log');
+ jest.mocked(fetchPlaybookRun).mockResolvedValue({error: 'Run fetch error'});
+
+ renderWithIntlAndTheme();
+
+ await waitFor(() => {
+ expect(logDebug).toHaveBeenCalledWith('error on fetchPlaybookRun', 'Run fetch error');
+ });
+ });
+
+ it('should handle Android back button press', () => {
+ const props = getBaseProps();
+ renderWithIntlAndTheme();
+
+ expect(useAndroidHardwareBackHandler).toHaveBeenCalledWith(
+ 'PlaybookPostUpdate',
+ expect.any(Function),
+ );
+
+ const backHandler = jest.mocked(useAndroidHardwareBackHandler).mock.calls[0][1];
+
+ act(() => {
+ backHandler();
+ });
+
+ expect(popTopScreen).toHaveBeenCalled();
+ expect(Keyboard.dismiss).toHaveBeenCalled();
+ });
+
+ it('should update reminder value correctly for different time options', async () => {
+ const props = getBaseProps();
+ const {getByTestId} = renderWithIntlAndTheme();
+
+ await waitFor(() => {
+ expect(getByTestId('playbooks.post_update.selector')).toBeTruthy();
+ });
+
+ const selector = getByTestId('playbooks.post_update.selector');
+ const timeOptions = [
+ {value: '30_minutes', expected: 1800},
+ {value: '1_hour', expected: 3600},
+ {value: '4_hours', expected: 14400},
+ {value: '1_day', expected: 86400},
+ {value: '7_days', expected: 604800},
+ ];
+
+ for (const option of timeOptions) {
+ // eslint-disable-next-line no-await-in-loop
+ await act(async () => {
+ selector.props.onSelected({value: option.value});
+ });
+
+ const lastCall = getLastCall(jest.mocked(useNavButtonPressed));
+ const postHandler = lastCall[2];
+
+ // eslint-disable-next-line no-await-in-loop
+ await act(async () => {
+ postHandler();
+ });
+
+ // eslint-disable-next-line no-await-in-loop
+ await waitFor(() => {
+ expect(postStatusUpdate).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.any(String),
+ expect.objectContaining({
+ reminder: option.expected,
+ }),
+ expect.any(Object),
+ );
+ });
+
+ jest.clearAllMocks();
+ }
+ });
+
+ it('should ignore deleted status posts when loading default message', async () => {
+ const props = getBaseProps();
+ const runWithDeletedStatusPost = TestHelper.fakePlaybookRun({
+ id: 'playbook-run-id',
+ reminder_message_template: 'Template message',
+ status_posts: [
+ {
+ id: 'deleted-post-id',
+ create_at: Date.now(),
+ delete_at: Date.now(),
+ },
+ {
+ id: 'valid-post-id',
+ create_at: Date.now(),
+ delete_at: 0,
+ },
+ ],
+ });
+
+ jest.mocked(fetchPlaybookRun).mockResolvedValue({run: runWithDeletedStatusPost});
+ const validPost = TestHelper.fakePostModel({
+ id: 'valid-post-id',
+ message: 'Valid post message',
+ createAt: Date.now(),
+ });
+ jest.mocked(getPosts).mockResolvedValue([validPost]);
+
+ const {getByTestId} = renderWithIntlAndTheme();
+
+ await waitFor(() => {
+ const input = getByTestId('FloatingTextInput');
+ expect(input).toHaveProp('value', 'Valid post message');
+ });
+ });
+
+ it('should use the reminder template if it cannot fetch the post', async () => {
+ const props = getBaseProps();
+ const runWithStatusPosts = TestHelper.fakePlaybookRun({
+ id: 'playbook-run-id',
+ reminder_message_template: 'Reminder template',
+ status_posts: [{id: 'status-post-id', create_at: Date.now(), delete_at: 0}],
+ });
+ jest.mocked(fetchPlaybookRun).mockResolvedValue({run: runWithStatusPosts});
+ jest.mocked(getPosts).mockResolvedValue([]);
+ const {getByTestId} = renderWithIntlAndTheme();
+ await waitFor(() => {
+ expect(getByTestId('FloatingTextInput')).toHaveProp('value', 'Reminder template');
+ });
+ });
+
+ it('should not call postStatusUpdate when alert is cancelled', async () => {
+ const props = getBaseProps();
+ const {getByTestId} = renderWithIntlAndTheme();
+
+ await waitFor(() => {
+ expect(getByTestId('playbooks.post_update.selector.also_mark_run_as_finished')).toBeTruthy();
+ });
+
+ const toggle = getByTestId('playbooks.post_update.selector.also_mark_run_as_finished');
+
+ await act(async () => {
+ toggle.props.action(true);
+ });
+
+ const lastCall = getLastCall(jest.mocked(useNavButtonPressed));
+ const postHandler = lastCall[2];
+ await act(async () => {
+ postHandler();
+ });
+
+ // Simulate alert cancel button press
+ const alertCall = jest.mocked(Alert.alert).mock.calls[0];
+ const cancelButton = alertCall[2]?.find((button) => button.text === 'Cancel');
+
+ await act(async () => {
+ cancelButton?.onPress?.();
+ });
+
+ await waitFor(() => {
+ expect(postStatusUpdate).not.toHaveBeenCalled();
+ });
+ });
+});
+
diff --git a/app/products/playbooks/screens/post_update/post_update.tsx b/app/products/playbooks/screens/post_update/post_update.tsx
index 4991e4850..c0ff377ab 100644
--- a/app/products/playbooks/screens/post_update/post_update.tsx
+++ b/app/products/playbooks/screens/post_update/post_update.tsx
@@ -21,6 +21,7 @@ import {fetchPlaybookRun, fetchPlaybookRunMetadata, postStatusUpdate} from '@pla
import {buildNavigationButton, popTopScreen, setButtons} from '@screens/navigation';
import {toSeconds} from '@utils/datetime';
import {logDebug} from '@utils/log';
+import {showPlaybookErrorSnackbar} from '@utils/snack_bar';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
@@ -130,7 +131,9 @@ const PostUpdate = ({
if (metadataRes.error) {
logDebug('error on fetchPlaybookRunMetadata', metadataRes.error);
} else {
- calculatedFollowersCount = metadataRes.metadata?.followers?.length ?? 0;
+ // We can safely assume that metadata is not undefined
+ // because we are checking for error above
+ calculatedFollowersCount = metadataRes.metadata!.followers.length;
}
const runRes = await fetchPlaybookRun(serverUrl, playbookRunId, true);
@@ -138,12 +141,12 @@ const PostUpdate = ({
logDebug('error on fetchPlaybookRun', runRes.error);
} else {
if (runRes.run?.status_update_broadcast_channels_enabled) {
- calculatedBroadcastChannelCount = runRes.run?.broadcast_channel_ids?.length ?? 0;
+ calculatedBroadcastChannelCount = runRes.run.broadcast_channel_ids.length;
}
calculatedDefaultMessage = runRes.run?.reminder_message_template ?? '';
const lastStatusPostMetadata = runRes.run?.status_posts?.slice().reverse().find((post) => !post.delete_at);
if (lastStatusPostMetadata?.id) {
- const lastStatusPost = (await getPosts(serverUrl, [lastStatusPostMetadata?.id ?? '']))[0];
+ const lastStatusPost = (await getPosts(serverUrl, [lastStatusPostMetadata.id]))[0];
if (lastStatusPost) {
calculatedDefaultMessage = lastStatusPost.message;
}
@@ -208,14 +211,17 @@ const PostUpdate = ({
close(componentId);
}, [componentId]);
- const onConfirm = useCallback(() => {
+ const onConfirm = useCallback(async () => {
close(componentId);
if (!channelId) {
// This should never happen, but this keeps typescript happy
logDebug('cannot post status update without a channel id');
return;
}
- postStatusUpdate(serverUrl, playbookRunId, {message: updateMessage, reminder: valueToTimeMap[nextUpdate], finishRun: alsoMarkRunAsFinished}, {user_id: userId, channel_id: channelId, team_id: teamId});
+ const {error} = await postStatusUpdate(serverUrl, playbookRunId, {message: updateMessage, reminder: valueToTimeMap[nextUpdate], finishRun: alsoMarkRunAsFinished}, {user_id: userId, channel_id: channelId, team_id: teamId});
+ if (error) {
+ showPlaybookErrorSnackbar();
+ }
}, [alsoMarkRunAsFinished, channelId, componentId, nextUpdate, playbookRunId, serverUrl, teamId, updateMessage, userId]);
const onPostUpdate = useCallback(() => {
@@ -259,7 +265,7 @@ const PostUpdate = ({
}, [runName, followersCount, broadcastChannelCount, styles.introMessageBold]);
if (loading) {
- return ;
+ return ;
}
let introMessage;
if (broadcastChannelCount + followersCount === 0) {
diff --git a/app/products/playbooks/screens/select_playbook/index.test.tsx b/app/products/playbooks/screens/select_playbook/index.test.tsx
new file mode 100644
index 000000000..740f207ea
--- /dev/null
+++ b/app/products/playbooks/screens/select_playbook/index.test.tsx
@@ -0,0 +1,309 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React, {type ComponentProps} from 'react';
+
+import {SYSTEM_IDENTIFIERS} from '@constants/database';
+import DatabaseManager from '@database/manager';
+import {act, renderWithEverything, waitFor} from '@test/intl-test-helper';
+import TestHelper from '@test/test_helper';
+
+import SelectPlaybookComponent from './select_playbook';
+
+import SelectPlaybook from './';
+
+import type ServerDataOperator from '@database/operator/server_data_operator';
+import type {Database} from '@nozbe/watermelondb';
+
+jest.mock('./select_playbook');
+jest.mocked(SelectPlaybookComponent).mockImplementation(
+ (props) => React.createElement('SelectPlaybook', {testID: 'select-playbook', ...props}),
+);
+
+const serverUrl = 'server-url';
+
+describe('SelectPlaybook', () => {
+ function getBaseProps(): ComponentProps {
+ return {
+ componentId: 'PlaybooksSelectPlaybook',
+ };
+ }
+
+ let database: Database;
+ let operator: ServerDataOperator;
+
+ beforeEach(async () => {
+ await DatabaseManager.init([serverUrl]);
+ const serverDatabaseAndOperator = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
+ database = serverDatabaseAndOperator.database;
+ operator = serverDatabaseAndOperator.operator;
+ });
+
+ afterEach(() => {
+ DatabaseManager.destroyServerDatabase(serverUrl);
+ jest.clearAllMocks();
+ });
+
+ it('should render correctly with no data', () => {
+ const props = getBaseProps();
+ const {getByTestId} = renderWithEverything(, {database});
+
+ const selectPlaybook = getByTestId('select-playbook');
+
+ // Default values from observables when no data exists
+ expect(selectPlaybook.props.currentUserId).toBe('');
+ expect(selectPlaybook.props.currentTeamId).toBe('');
+ expect(selectPlaybook.props.playbooksUsedInChannel).toEqual(new Set());
+ });
+
+ it('should render correctly with both current user and team data', async () => {
+ await operator.handleSystem({
+ systems: [
+ {
+ id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID,
+ value: 'current-user-id',
+ },
+ {
+ id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID,
+ value: 'current-team-id',
+ },
+ ],
+ prepareRecordsOnly: false,
+ });
+
+ const props = getBaseProps();
+ const {getByTestId} = renderWithEverything(, {database});
+
+ const selectPlaybook = getByTestId('select-playbook');
+ expect(selectPlaybook.props.currentUserId).toBe('current-user-id');
+ expect(selectPlaybook.props.currentTeamId).toBe('current-team-id');
+ expect(selectPlaybook.props.playbooksUsedInChannel).toEqual(new Set());
+ });
+
+ it('should render correctly with playbooks used in channel', async () => {
+ const channelId = 'channel-id-1';
+ const playbookId1 = 'playbook-id-1';
+ const playbookId2 = 'playbook-id-2';
+
+ // Set up current channel ID
+ await operator.handleSystem({
+ systems: [{
+ id: SYSTEM_IDENTIFIERS.CURRENT_CHANNEL_ID,
+ value: channelId,
+ }],
+ prepareRecordsOnly: false,
+ });
+
+ // Create playbook runs for the channel
+ const mockRuns = [
+ TestHelper.fakePlaybookRun({id: 'run-1', channel_id: channelId, playbook_id: playbookId1}),
+ TestHelper.fakePlaybookRun({id: 'run-2', channel_id: channelId, playbook_id: playbookId2}),
+ TestHelper.fakePlaybookRun({id: 'run-3', channel_id: channelId, playbook_id: playbookId1}), // Duplicate playbook ID
+ ];
+
+ await operator.handlePlaybookRun({
+ runs: mockRuns,
+ prepareRecordsOnly: false,
+ removeAssociatedRecords: false,
+ });
+
+ const props = getBaseProps();
+ const {getByTestId} = renderWithEverything(, {database});
+
+ await waitFor(() => {
+ const selectPlaybook = getByTestId('select-playbook');
+ expect(selectPlaybook.props.playbooksUsedInChannel).toBeInstanceOf(Set);
+ expect(selectPlaybook.props.playbooksUsedInChannel.size).toBe(2);
+ expect(selectPlaybook.props.playbooksUsedInChannel.has(playbookId1)).toBe(true);
+ expect(selectPlaybook.props.playbooksUsedInChannel.has(playbookId2)).toBe(true);
+ });
+ });
+
+ it('should return empty set when channel has no playbook runs', async () => {
+ const channelId = 'channel-id-1';
+
+ // Set up current channel ID
+ await operator.handleSystem({
+ systems: [{
+ id: SYSTEM_IDENTIFIERS.CURRENT_CHANNEL_ID,
+ value: channelId,
+ }],
+ prepareRecordsOnly: false,
+ });
+
+ const props = getBaseProps();
+ const {getByTestId} = renderWithEverything(, {database});
+
+ await waitFor(() => {
+ const selectPlaybook = getByTestId('select-playbook');
+ expect(selectPlaybook.props.playbooksUsedInChannel).toEqual(new Set());
+ });
+ });
+
+ it('should update playbooksUsedInChannel when channel changes', async () => {
+ const channelId1 = 'channel-id-1';
+ const channelId2 = 'channel-id-2';
+ const playbookId1 = 'playbook-id-1';
+ const playbookId2 = 'playbook-id-2';
+
+ // Set up initial channel
+ await operator.handleSystem({
+ systems: [{
+ id: SYSTEM_IDENTIFIERS.CURRENT_CHANNEL_ID,
+ value: channelId1,
+ }],
+ prepareRecordsOnly: false,
+ });
+
+ // Create playbook run for first channel
+ const mockRun1 = TestHelper.fakePlaybookRun({
+ id: 'run-1',
+ channel_id: channelId1,
+ playbook_id: playbookId1,
+ });
+
+ await operator.handlePlaybookRun({
+ runs: [mockRun1],
+ prepareRecordsOnly: false,
+ removeAssociatedRecords: false,
+ });
+
+ const props = getBaseProps();
+ const {getByTestId} = renderWithEverything(, {database});
+
+ await waitFor(() => {
+ const selectPlaybook = getByTestId('select-playbook');
+ expect(selectPlaybook.props.playbooksUsedInChannel.has(playbookId1)).toBe(true);
+ });
+
+ // Create playbook run for second channel
+ const mockRun2 = TestHelper.fakePlaybookRun({
+ id: 'run-2',
+ channel_id: channelId2,
+ playbook_id: playbookId2,
+ });
+
+ await operator.handlePlaybookRun({
+ runs: [mockRun2],
+ prepareRecordsOnly: false,
+ removeAssociatedRecords: false,
+ });
+
+ // Switch to second channel
+ await act(async () => {
+ await operator.handleSystem({
+ systems: [{
+ id: SYSTEM_IDENTIFIERS.CURRENT_CHANNEL_ID,
+ value: channelId2,
+ }],
+ prepareRecordsOnly: false,
+ });
+ });
+
+ await waitFor(() => {
+ const selectPlaybook = getByTestId('select-playbook');
+ expect(selectPlaybook.props.playbooksUsedInChannel.has(playbookId1)).toBe(false);
+ expect(selectPlaybook.props.playbooksUsedInChannel.has(playbookId2)).toBe(true);
+ });
+ });
+
+ it('should update observables when data changes', async () => {
+ await operator.handleSystem({
+ systems: [
+ {
+ id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID,
+ value: 'current-user-id',
+ },
+ {
+ id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID,
+ value: 'current-team-id',
+ },
+ ],
+ prepareRecordsOnly: false,
+ });
+
+ const props = getBaseProps();
+ const {getByTestId} = renderWithEverything(, {database});
+
+ const selectPlaybook = getByTestId('select-playbook');
+ expect(selectPlaybook.props.currentUserId).toBe('current-user-id');
+ expect(selectPlaybook.props.currentTeamId).toBe('current-team-id');
+
+ await act(async () => {
+ // Update current user ID
+ await operator.handleSystem({
+ systems: [{
+ id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID,
+ value: 'new-user-id',
+ }],
+ prepareRecordsOnly: false,
+ });
+ });
+
+ await waitFor(() => {
+ expect(selectPlaybook.props.currentUserId).toBe('new-user-id');
+ expect(selectPlaybook.props.currentTeamId).toBe('current-team-id');
+ });
+
+ await act(async () => {
+ // Update current team ID
+ await operator.handleSystem({
+ systems: [{
+ id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID,
+ value: 'new-team-id',
+ }],
+ prepareRecordsOnly: false,
+ });
+ });
+
+ await waitFor(() => {
+ expect(selectPlaybook.props.currentUserId).toBe('new-user-id');
+ expect(selectPlaybook.props.currentTeamId).toBe('new-team-id');
+ });
+ });
+
+ it('should extract unique playbook IDs from runs', async () => {
+ const channelId = 'channel-id-1';
+ const playbookId1 = 'playbook-id-1';
+ const playbookId2 = 'playbook-id-2';
+ const playbookId3 = 'playbook-id-3';
+
+ // Set up current channel ID
+ await operator.handleSystem({
+ systems: [{
+ id: SYSTEM_IDENTIFIERS.CURRENT_CHANNEL_ID,
+ value: channelId,
+ }],
+ prepareRecordsOnly: false,
+ });
+
+ // Create playbook runs with duplicate playbook IDs
+ const mockRuns = [
+ TestHelper.fakePlaybookRun({id: 'run-1', channel_id: channelId, playbook_id: playbookId1}),
+ TestHelper.fakePlaybookRun({id: 'run-2', channel_id: channelId, playbook_id: playbookId2}),
+ TestHelper.fakePlaybookRun({id: 'run-3', channel_id: channelId, playbook_id: playbookId1}), // Duplicate
+ TestHelper.fakePlaybookRun({id: 'run-4', channel_id: channelId, playbook_id: playbookId3}),
+ TestHelper.fakePlaybookRun({id: 'run-5', channel_id: channelId, playbook_id: playbookId2}), // Duplicate
+ ];
+
+ await operator.handlePlaybookRun({
+ runs: mockRuns,
+ prepareRecordsOnly: false,
+ removeAssociatedRecords: false,
+ });
+
+ const props = getBaseProps();
+ const {getByTestId} = renderWithEverything(, {database});
+
+ await waitFor(() => {
+ const selectPlaybook = getByTestId('select-playbook');
+
+ // Should only contain unique playbook IDs
+ expect(selectPlaybook.props.playbooksUsedInChannel.size).toBe(3);
+ expect(selectPlaybook.props.playbooksUsedInChannel.has(playbookId1)).toBe(true);
+ expect(selectPlaybook.props.playbooksUsedInChannel.has(playbookId2)).toBe(true);
+ expect(selectPlaybook.props.playbooksUsedInChannel.has(playbookId3)).toBe(true);
+ });
+ });
+});
+
diff --git a/app/products/playbooks/screens/select_playbook/playbook_row.test.tsx b/app/products/playbooks/screens/select_playbook/playbook_row.test.tsx
new file mode 100644
index 000000000..f4e9720a0
--- /dev/null
+++ b/app/products/playbooks/screens/select_playbook/playbook_row.test.tsx
@@ -0,0 +1,139 @@
+// 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, {type ComponentProps} from 'react';
+
+import CompassIcon from '@components/compass_icon';
+import {renderWithIntlAndTheme} from '@test/intl-test-helper';
+import TestHelper from '@test/test_helper';
+
+import PlaybookRow from './playbook_row';
+
+jest.mock('@components/compass_icon', () => ({
+ __esModule: true,
+ default: jest.fn(),
+}));
+jest.mocked(CompassIcon).mockImplementation(
+ (props) => React.createElement('CompassIcon', {testID: 'compass-icon', ...props}) as any,
+);
+
+describe('PlaybookRow', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ function getBaseProps(): ComponentProps {
+ return {
+ playbook: TestHelper.fakePlaybook({
+ title: 'Test Playbook',
+ }),
+ onPress: jest.fn(),
+ };
+ }
+
+ it('renders playbook title correctly', () => {
+ const props = getBaseProps();
+ const {getByText} = renderWithIntlAndTheme(
+ ,
+ );
+
+ expect(getByText('Test Playbook')).toBeTruthy();
+ });
+
+ it('renders public playbook icon correctly', () => {
+ const props = getBaseProps();
+ props.playbook.public = true;
+
+ const {getByTestId} = renderWithIntlAndTheme(
+ ,
+ );
+
+ const icon = getByTestId('compass-icon');
+ expect(icon.props.name).toBe('book-outline');
+ });
+
+ it('renders private playbook icon correctly', () => {
+ const props = getBaseProps();
+ props.playbook.public = false;
+
+ const {getByTestId} = renderWithIntlAndTheme(
+ ,
+ );
+
+ const icon = getByTestId('compass-icon');
+ expect(icon.props.name).toBe('book-lock-outline');
+ });
+
+ it('displays "Never used" when last_run_at is 0', () => {
+ const props = getBaseProps();
+ props.playbook.last_run_at = 0;
+
+ const {getByText} = renderWithIntlAndTheme(
+ ,
+ );
+
+ expect(getByText(/Never used/)).toBeTruthy();
+ });
+
+ it('displays "Last used" when last_run_at is set', () => {
+ const lastRunAt = Date.now() - 3600000; // 1 hour ago
+ const props = getBaseProps();
+ props.playbook.last_run_at = lastRunAt;
+
+ const {getByText} = renderWithIntlAndTheme(
+ ,
+ );
+
+ expect(getByText(/Last used/)).toBeTruthy();
+ });
+
+ it('displays "No runs in progress" when active_runs is 0', () => {
+ const props = getBaseProps();
+ props.playbook.active_runs = 0;
+
+ const {getByText} = renderWithIntlAndTheme(
+ ,
+ );
+
+ expect(getByText(/No runs in progress/)).toBeTruthy();
+ });
+
+ it('displays single run in progress correctly', () => {
+ const props = getBaseProps();
+ props.playbook.active_runs = 1;
+
+ const {getByText} = renderWithIntlAndTheme(
+ ,
+ );
+
+ expect(getByText(/1 run in progress/)).toBeTruthy();
+ });
+
+ it('displays multiple runs in progress correctly', () => {
+ const props = getBaseProps();
+ props.playbook.active_runs = 5;
+
+ const {getByText} = renderWithIntlAndTheme(
+ ,
+ );
+
+ expect(getByText(/5 runs in progress/)).toBeTruthy();
+ });
+
+ it('calls onPress when pressed', async () => {
+ const props = getBaseProps();
+
+ const {getByText} = renderWithIntlAndTheme(
+ ,
+ );
+
+ await act(async () => {
+ fireEvent.press(getByText('Test Playbook'));
+ });
+
+ expect(props.onPress).toHaveBeenCalledTimes(1);
+ expect(props.onPress).toHaveBeenCalledWith(props.playbook);
+ });
+});
+
diff --git a/app/products/playbooks/screens/select_playbook/playbook_row.tsx b/app/products/playbooks/screens/select_playbook/playbook_row.tsx
index eecfb511c..756b7c9f4 100644
--- a/app/products/playbooks/screens/select_playbook/playbook_row.tsx
+++ b/app/products/playbooks/screens/select_playbook/playbook_row.tsx
@@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
-import React from 'react';
+import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {View, Text, TouchableOpacity} from 'react-native';
@@ -13,8 +13,7 @@ import {typography} from '@utils/typography';
type Props = {
playbook: Playbook;
- onPress?: (playbook: Playbook) => void;
- testID?: string;
+ onPress: (playbook: Playbook) => void;
};
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
@@ -41,14 +40,17 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
};
});
-const PlaybookRow = ({playbook, onPress, testID}: Props) => {
+const PlaybookRow = ({
+ playbook,
+ onPress,
+}: Props) => {
const theme = useTheme();
const intl = useIntl();
const styles = getStyleSheet(theme);
- const handlePress = () => {
- onPress?.(playbook);
- };
+ const handlePress = useCallback(() => {
+ onPress(playbook);
+ }, [onPress, playbook]);
const formatLastUsed = (lastRunAt: number) => {
if (!lastRunAt) {
@@ -85,7 +87,6 @@ const PlaybookRow = ({playbook, onPress, testID}: Props) => {
({
+ switchToChannelById: jest.fn(),
+}));
+
+jest.mock('@playbooks/actions/remote/playbooks', () => ({
+ fetchPlaybooks: jest.fn(),
+}));
+
+jest.mock('@playbooks/actions/remote/runs', () => ({
+ fetchPlaybookRunsForChannel: jest.fn(),
+}));
+
+jest.mock('@playbooks/screens/navigation', () => ({
+ goToStartARun: jest.fn(),
+ goToPlaybookRun: jest.fn(),
+}));
+
+jest.mock('@context/server', () => ({
+ useServerUrl: jest.fn(),
+}));
+
+jest.mock('@hooks/android_back_handler', () => jest.fn());
+
+jest.mock('@managers/security_manager', () => ({
+ getShieldScreenId: jest.fn((id) => `shield_${id}`),
+}));
+
+jest.mock('./playbook_row', () => ({
+ __esModule: true,
+ default: jest.fn(),
+}));
+jest.mocked(PlaybookRow).mockImplementation(
+ (props) => React.createElement('PlaybookRow', {testID: 'playbook-row', ...props}),
+);
+
+jest.mock('@components/search', () => ({
+ __esModule: true,
+ default: jest.fn(),
+}));
+jest.mocked(SearchBar).mockImplementation(
+ (props) => React.createElement('SearchBar', {...props}),
+);
+
+const mockServerUrl = 'https://test-server.com';
+
+describe('SelectPlaybook', () => {
+ function getBaseProps(): ComponentProps {
+ return {
+ currentTeamId: 'team-id-1',
+ currentUserId: 'user-id-1',
+ componentId: 'PlaybooksSelectPlaybook',
+ playbooksUsedInChannel: new Set(),
+ };
+ }
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ jest.mocked(useServerUrl).mockReturnValue(mockServerUrl);
+ jest.mocked(fetchPlaybooks).mockResolvedValue({
+ data: {
+ total_count: 0,
+ page_count: 0,
+ has_more: false,
+ items: [],
+ },
+ });
+ jest.mocked(fetchPlaybookRunsForChannel).mockResolvedValue({});
+ jest.mocked(switchToChannelById).mockResolvedValue({});
+ jest.mocked(goToStartARun).mockResolvedValue();
+ jest.mocked(goToPlaybookRun).mockResolvedValue();
+ });
+
+ it('renders correctly with no data', async () => {
+ const props = getBaseProps();
+ const {getByTestId, getByText} = renderWithIntlAndTheme();
+
+ expect(getByTestId('selector.search_bar')).toBeTruthy();
+ await waitFor(() => {
+ expect(getByText('No Results')).toBeVisible();
+ });
+ });
+
+ it('loads playbooks on mount', async () => {
+ const mockPlaybooks = [
+ TestHelper.fakePlaybook({id: 'playbook-1', title: 'Playbook 1'}),
+ TestHelper.fakePlaybook({id: 'playbook-2', title: 'Playbook 2', members: [{user_id: 'user-id-1', roles: []}]}),
+ ];
+
+ jest.mocked(fetchPlaybooks).mockResolvedValueOnce({
+ data: {
+ total_count: 2,
+ page_count: 1,
+ has_more: false,
+ items: mockPlaybooks,
+ },
+ });
+
+ const props = getBaseProps();
+ const {getAllByTestId, getByText, queryByText} = renderWithIntlAndTheme();
+
+ await waitFor(() => {
+ expect(fetchPlaybooks).toHaveBeenCalledWith(mockServerUrl, {
+ team_id: 'team-id-1',
+ page: 0,
+ });
+ });
+
+ await waitFor(() => {
+ expect(getByText('Your Playbooks')).toBeVisible();
+ expect(queryByText('In This Channel')).not.toBeVisible();
+ expect(getByText('Other Playbooks')).toBeVisible();
+
+ const allPlaybookRows = getAllByTestId('playbook-row');
+ expect(allPlaybookRows).toHaveLength(2);
+
+ // We assume Your Playbooks section appears first in the list
+ expect(allPlaybookRows[0]).toHaveProp('playbook', mockPlaybooks[1]);
+ expect(allPlaybookRows[1]).toHaveProp('playbook', mockPlaybooks[0]);
+ });
+ });
+
+ it('shows loading indicator while fetching initial data', async () => {
+ let resolveFetch: () => void;
+ const fetchPromise = new Promise((resolve) => {
+ resolveFetch = () => resolve({
+ data: {
+ total_count: 0,
+ page_count: 0,
+ has_more: false,
+ items: [],
+ },
+ });
+ });
+
+ jest.mocked(fetchPlaybooks).mockReturnValueOnce(fetchPromise as any);
+
+ const props = getBaseProps();
+ const {getByTestId, queryByTestId} = renderWithIntlAndTheme();
+
+ await waitFor(() => {
+ expect(getByTestId('selector.loading')).toBeVisible();
+ });
+
+ act(() => {
+ resolveFetch();
+ });
+
+ await waitFor(() => {
+ expect(queryByTestId('selector.loading')).not.toBeVisible();
+ });
+ });
+
+ it('handles pagination correctly when has_more is true', async () => {
+ const firstPage = [
+ TestHelper.fakePlaybook({id: 'playbook-1', title: 'Playbook 1'}),
+ ];
+ const secondPage = [
+ TestHelper.fakePlaybook({id: 'playbook-2', title: 'Playbook 2'}),
+ ];
+
+ jest.mocked(fetchPlaybooks).
+ mockResolvedValueOnce({
+ data: {
+ total_count: 2,
+ page_count: 1,
+ has_more: true,
+ items: firstPage,
+ },
+ }).
+ mockResolvedValueOnce({
+ data: {
+ total_count: 2,
+ page_count: 1,
+ has_more: false,
+ items: secondPage,
+ },
+ });
+
+ const props = getBaseProps();
+ const {getByTestId, getAllByTestId} = renderWithIntlAndTheme();
+
+ await waitFor(() => {
+ expect(fetchPlaybooks).toHaveBeenCalledTimes(1);
+ expect(fetchPlaybooks).toHaveBeenCalledWith(mockServerUrl, {
+ team_id: 'team-id-1',
+ page: 0,
+ });
+ });
+
+ act(() => {
+ const list = getByTestId('selector.section_list');
+ fireEvent(list, 'onEndReached');
+ });
+
+ await waitFor(() => {
+ expect(fetchPlaybooks).toHaveBeenCalledWith(mockServerUrl, {
+ team_id: 'team-id-1',
+ page: 1,
+ });
+ expect(fetchPlaybooks).toHaveBeenCalledTimes(2);
+ const allPlaybookRows = getAllByTestId('playbook-row');
+ expect(allPlaybookRows).toHaveLength(2);
+ expect(allPlaybookRows[0]).toHaveProp('playbook', firstPage[0]);
+ expect(allPlaybookRows[1]).toHaveProp('playbook', secondPage[0]);
+ });
+ });
+
+ it('handles search correctly', async () => {
+ const searchTerm = 'test search';
+ const searchResults = [
+ TestHelper.fakePlaybook({id: 'playbook-search-1', title: 'Search Result 1'}),
+ ];
+
+ const props = getBaseProps();
+ const {getByTestId, queryByText} = renderWithIntlAndTheme();
+
+ jest.mocked(fetchPlaybooks).mockResolvedValueOnce({
+ data: {
+ total_count: 1,
+ page_count: 1,
+ has_more: false,
+ items: searchResults,
+ },
+ });
+
+ const searchBar = getByTestId('selector.search_bar');
+ act(() => {
+ searchBar.props.onChangeText(searchTerm);
+ });
+
+ await waitFor(() => {
+ expect(fetchPlaybooks).toHaveBeenCalledWith(mockServerUrl, {
+ team_id: 'team-id-1',
+ search_term: searchTerm,
+ sort: 'last_run_at',
+ });
+ });
+
+ await waitFor(() => {
+ // Ensure no sections are visible on search results
+ expect(queryByText('In This Channel')).not.toBeVisible();
+ expect(queryByText('Your Playbooks')).not.toBeVisible();
+ expect(queryByText('Other Playbooks')).not.toBeVisible();
+ expect(getByTestId('playbook-row')).toHaveProp('playbook', searchResults[0]);
+ });
+ });
+
+ it('clears search when search term is empty', async () => {
+ const props = getBaseProps();
+
+ jest.mocked(fetchPlaybooks).mockResolvedValueOnce({
+ data: {
+ total_count: 0,
+ page_count: 0,
+ has_more: false,
+ items: [TestHelper.fakePlaybook({id: 'playbook-1', title: 'Playbook 1'})],
+ },
+ });
+
+ const {getByTestId, getByText, queryByText} = renderWithIntlAndTheme();
+
+ const searchBar = getByTestId('selector.search_bar');
+
+ // Initial load
+ expect(fetchPlaybooks).toHaveBeenCalledTimes(1);
+
+ // Set a search term
+ act(() => {
+ searchBar.props.onChangeText('test');
+ });
+
+ await waitFor(() => {
+ expect(fetchPlaybooks).toHaveBeenCalledTimes(2); // First load + search
+ expect(getByText('No Results')).toBeVisible();
+ });
+
+ // Clear search
+ act(() => {
+ searchBar.props.onChangeText('');
+ });
+
+ await waitFor(() => {
+ expect(fetchPlaybooks).toHaveBeenCalledTimes(2); // Only load and first search
+ expect(queryByText('No Results')).not.toBeVisible();
+ expect(getByText('Other Playbooks')).toBeVisible();
+ });
+ });
+
+ it('cancels previous search when new search is initiated', async () => {
+ const props = getBaseProps();
+ const {getByTestId} = renderWithIntlAndTheme();
+
+ // Clear the mock call from initial render
+ jest.mocked(fetchPlaybooks).mockClear();
+
+ const searchBar = getByTestId('selector.search_bar');
+
+ // Start first search
+ act(() => {
+ searchBar.props.onChangeText('first');
+ });
+
+ // Start second search before first completes
+ act(() => {
+ searchBar.props.onChangeText('second');
+ });
+
+ await waitFor(() => {
+ // Should only call with the last search term
+ expect(fetchPlaybooks).toHaveBeenCalledWith(
+ mockServerUrl,
+ expect.objectContaining({
+ search_term: 'second',
+ }),
+ );
+ expect(fetchPlaybooks).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it('organizes playbooks into sections correctly', async () => {
+ const currentUserId = 'user-id-1';
+ const otherUserId = 'user-id-2';
+ const playbookInChannel = TestHelper.fakePlaybook({
+ id: 'playbook-in-channel',
+ title: 'In Channel Playbook',
+ members: [{user_id: otherUserId, roles: []}],
+ });
+ const userPlaybook = TestHelper.fakePlaybook({
+ id: 'playbook-user',
+ title: 'User Playbook',
+ members: [{user_id: currentUserId, roles: []}],
+ });
+ const otherPlaybook = TestHelper.fakePlaybook({
+ id: 'playbook-other',
+ title: 'Other Playbook',
+ members: [{user_id: otherUserId, roles: []}],
+ });
+
+ jest.mocked(fetchPlaybooks).mockResolvedValueOnce({
+ data: {
+ total_count: 3,
+ page_count: 1,
+ has_more: false,
+ items: [otherPlaybook, playbookInChannel, userPlaybook],
+ },
+ });
+
+ const props = getBaseProps();
+ props.playbooksUsedInChannel = new Set(['playbook-in-channel']);
+
+ const {getByText, getAllByTestId} = renderWithIntlAndTheme();
+
+ await waitFor(() => {
+ expect(getByText('In This Channel')).toBeTruthy();
+ expect(getByText('Your Playbooks')).toBeTruthy();
+ expect(getByText('Other Playbooks')).toBeTruthy();
+ const allPlaybookRows = getAllByTestId('playbook-row');
+ expect(allPlaybookRows).toHaveLength(3);
+
+ // We assume the order of sections
+ expect(allPlaybookRows[0]).toHaveProp('playbook', playbookInChannel);
+ expect(allPlaybookRows[1]).toHaveProp('playbook', userPlaybook);
+ expect(allPlaybookRows[2]).toHaveProp('playbook', otherPlaybook);
+ });
+ });
+
+ it('filters out empty sections', async () => {
+ const userPlaybook = TestHelper.fakePlaybook({
+ id: 'playbook-user',
+ title: 'User Playbook',
+ members: [{user_id: 'user-id-1', roles: []}],
+ });
+
+ jest.mocked(fetchPlaybooks).mockResolvedValueOnce({
+ data: {
+ total_count: 1,
+ page_count: 1,
+ has_more: false,
+ items: [userPlaybook],
+ },
+ });
+
+ const props = getBaseProps();
+ const {queryByText, getByText} = renderWithIntlAndTheme();
+
+ await waitFor(() => {
+ expect(queryByText('In This Channel')).not.toBeVisible();
+ expect(getByText('Your Playbooks')).toBeVisible();
+ expect(queryByText('Other Playbooks')).not.toBeVisible();
+ });
+ });
+
+ it('calls goToStartARun when playbook is pressed', async () => {
+ const mockPlaybook = TestHelper.fakePlaybook({
+ id: 'playbook-1',
+ title: 'Test Playbook',
+ });
+
+ jest.mocked(fetchPlaybooks).mockResolvedValueOnce({
+ data: {
+ total_count: 1,
+ page_count: 1,
+ has_more: false,
+ items: [mockPlaybook],
+ },
+ });
+
+ const props = getBaseProps();
+ const {getByTestId} = renderWithIntlAndTheme();
+
+ await waitFor(() => {
+ const playbookRow = getByTestId('playbook-row');
+ expect(playbookRow).toBeTruthy();
+
+ act(() => {
+ playbookRow.props.onPress(mockPlaybook);
+ });
+ });
+
+ await waitFor(() => {
+ expect(goToStartARun).toHaveBeenCalledWith(
+ expect.any(Object), // intl
+ expect.any(Object), // theme
+ mockPlaybook,
+ expect.any(Function), // onRunCreated callback
+ undefined, // channelId
+ );
+ });
+ });
+
+ it('handles run creation correctly', async () => {
+ const mockPlaybook = TestHelper.fakePlaybook({
+ id: 'playbook-1',
+ title: 'Test Playbook',
+ });
+ const mockRun = TestHelper.fakePlaybookRun({
+ id: 'run-1',
+ channel_id: 'channel-id-1',
+ });
+
+ jest.mocked(fetchPlaybooks).mockResolvedValueOnce({
+ data: {
+ total_count: 1,
+ page_count: 1,
+ has_more: false,
+ items: [mockPlaybook],
+ },
+ });
+
+ const props = getBaseProps();
+ const {getByTestId} = renderWithIntlAndTheme();
+
+ await waitFor(() => {
+ const playbookRow = getByTestId('playbook-row');
+ expect(playbookRow).toBeTruthy();
+
+ act(() => {
+ playbookRow.props.onPress(mockPlaybook);
+ });
+ });
+
+ await waitFor(() => {
+ expect(goToStartARun).toHaveBeenCalled();
+ });
+
+ // Get the onRunCreated callback and call it
+ const onRunCreated = jest.mocked(goToStartARun).mock.calls[0][3];
+ act(() => {
+ onRunCreated(mockRun);
+ });
+
+ await waitFor(() => {
+ expect(popTo).toHaveBeenCalledWith(Screens.HOME);
+ expect(fetchPlaybookRunsForChannel).toHaveBeenCalledWith(mockServerUrl, 'channel-id-1');
+ expect(switchToChannelById).toHaveBeenCalledWith(mockServerUrl, 'channel-id-1');
+ expect(goToPlaybookRun).toHaveBeenCalledWith(expect.any(Object), 'run-1');
+ });
+ });
+
+ it('should clear all resutls when receiving a search failure', async () => {
+ const props = getBaseProps();
+ const {getByTestId, getAllByTestId, getByText} = renderWithIntlAndTheme();
+
+ // Add some search results
+ jest.mocked(fetchPlaybooks).mockResolvedValueOnce({
+ data: {
+ total_count: 1,
+ page_count: 1,
+ has_more: false,
+ items: [TestHelper.fakePlaybook({id: 'playbook-1', title: 'Playbook 1'})],
+ },
+ });
+
+ const searchBar = getByTestId('selector.search_bar');
+ act(() => {
+ searchBar.props.onChangeText('test');
+ });
+
+ await waitFor(() => {
+ expect(getAllByTestId('playbook-row')).toHaveLength(1);
+ });
+
+ // Have a search error
+ jest.mocked(fetchPlaybooks).mockResolvedValueOnce({error: 'Search failed'});
+ act(() => {
+ searchBar.props.onChangeText('test2');
+ });
+
+ await waitFor(() => {
+ expect(getByText('No Results')).toBeVisible();
+ });
+ });
+
+ it('handles Android back button', () => {
+ const props = getBaseProps();
+ renderWithIntlAndTheme();
+
+ expect(useAndroidHardwareBackHandler).toHaveBeenCalledWith(props.componentId, expect.any(Function));
+
+ const closeHandler = jest.mocked(useAndroidHardwareBackHandler).mock.calls[0][1];
+ act(() => {
+ closeHandler();
+ });
+
+ expect(popTopScreen).toHaveBeenCalled();
+ });
+
+ it('should not load more plyabooks while it is loading', async () => {
+ const props = getBaseProps();
+
+ let resolveFetch: () => void;
+ jest.mocked(fetchPlaybooks).mockReturnValueOnce(new Promise((resolve) => {
+ resolveFetch = () => resolve({
+ data: {
+ total_count: 1,
+ page_count: 1,
+ has_more: true,
+ items: [TestHelper.fakePlaybook({id: 'playbook-1', title: 'Playbook 1'})],
+ },
+ });
+ }));
+
+ const {getByTestId, queryByTestId} = renderWithIntlAndTheme();
+ expect(fetchPlaybooks).toHaveBeenCalledTimes(1);
+
+ act(() => {
+ const list = getByTestId('selector.section_list');
+ fireEvent(list, 'onEndReached');
+ });
+
+ expect(getByTestId('selector.loading')).toBeVisible();
+ expect(fetchPlaybooks).toHaveBeenCalledTimes(1);
+
+ act(() => {
+ resolveFetch();
+ });
+
+ await waitFor(() => {
+ expect(queryByTestId('selector.loading')).not.toBeVisible();
+ expect(fetchPlaybooks).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it('should not load more playbooks if there are no more', async () => {
+ const props = getBaseProps();
+
+ jest.mocked(fetchPlaybooks).mockResolvedValueOnce({
+ data: {
+ total_count: 1,
+ page_count: 1,
+ has_more: false,
+ items: [TestHelper.fakePlaybook({id: 'playbook-1', title: 'Playbook 1'})],
+ },
+ });
+
+ const {getByTestId} = renderWithIntlAndTheme();
+ expect(fetchPlaybooks).toHaveBeenCalledTimes(1);
+ await waitFor(() => {
+ expect(getByTestId('playbook-row')).toBeVisible();
+ });
+
+ act(() => {
+ const list = getByTestId('selector.section_list');
+ fireEvent(list, 'onEndReached');
+ });
+
+ await waitFor(() => {
+ expect(fetchPlaybooks).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it('should handle load error gracefully', async () => {
+ // Have a load error
+ jest.mocked(fetchPlaybooks).mockResolvedValueOnce({error: 'Load failed'});
+
+ const props = getBaseProps();
+ const {getByText} = renderWithIntlAndTheme();
+
+ await waitFor(() => {
+ expect(getByText('No Results')).toBeVisible();
+ });
+ });
+});
diff --git a/app/products/playbooks/screens/select_playbook/select_playbook.tsx b/app/products/playbooks/screens/select_playbook/select_playbook.tsx
index 25b59e76a..1456363b7 100644
--- a/app/products/playbooks/screens/select_playbook/select_playbook.tsx
+++ b/app/products/playbooks/screens/select_playbook/select_playbook.tsx
@@ -117,7 +117,7 @@ function SelectPlaybook({
const [searchResults, setSearchResults] = useState([]);
const page = useRef(-1);
- const next = useRef(true);
+ const hasMore = useRef(true);
// Callbacks
const clearSearch = useCallback(() => {
@@ -127,7 +127,7 @@ function SelectPlaybook({
}, []);
const loadMore = useCallback(async () => {
- if (next.current && !loading) {
+ if (hasMore.current && !loading) {
setLoading(true);
const result = await fetchPlaybooks(serverUrl, {
team_id: currentTeamId,
@@ -139,7 +139,7 @@ function SelectPlaybook({
}
page.current++;
- next.current = Boolean(result.data?.has_more);
+ hasMore.current = Boolean(result.data?.has_more);
setLoading(false);
}
}, [loading, serverUrl, currentTeamId]);
@@ -166,6 +166,8 @@ function SelectPlaybook({
if (result.data) {
setSearchResults(result.data.items);
+ } else {
+ setSearchResults(EMPTY_DATA);
}
setSearching(false);
@@ -247,6 +249,7 @@ function SelectPlaybook({
color={theme.buttonBg}
containerStyle={style.loadingContainer}
size='large'
+ testID='selector.loading'
/>
);
}, [loading, style.loadingContainer, theme.buttonBg]);
@@ -311,7 +314,6 @@ function SelectPlaybook({
style={style.container}
>
)}
{!term && (
@@ -343,6 +345,7 @@ function SelectPlaybook({
ListEmptyComponent={renderNoResults}
onEndReached={loadMore}
ListFooterComponent={renderLoading}
+ testID='selector.section_list'
/>
)}
diff --git a/app/products/playbooks/screens/start_a_run/index.test.tsx b/app/products/playbooks/screens/start_a_run/index.test.tsx
new file mode 100644
index 000000000..a28c4334e
--- /dev/null
+++ b/app/products/playbooks/screens/start_a_run/index.test.tsx
@@ -0,0 +1,141 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React, {type ComponentProps} from 'react';
+
+import {SYSTEM_IDENTIFIERS} from '@constants/database';
+import DatabaseManager from '@database/manager';
+import {act, renderWithEverything, waitFor} from '@test/intl-test-helper';
+import TestHelper from '@test/test_helper';
+
+import StartARunComponent from './start_a_run';
+
+import StartARun from './';
+
+import type ServerDataOperator from '@database/operator/server_data_operator';
+import type {Database} from '@nozbe/watermelondb';
+
+jest.mock('./start_a_run');
+jest.mocked(StartARunComponent).mockImplementation(
+ (props) => React.createElement('StartARun', {testID: 'start-a-run', ...props}),
+);
+
+const serverUrl = 'server-url';
+
+describe('StartARun', () => {
+ function getBaseProps(): ComponentProps {
+ return {
+ componentId: 'PlaybooksStartARun',
+ onRunCreated: jest.fn(),
+ playbook: TestHelper.fakePlaybook({
+ id: 'playbook-id',
+ title: 'Test Playbook',
+ }),
+ };
+ }
+
+ let database: Database;
+ let operator: ServerDataOperator;
+
+ beforeEach(async () => {
+ await DatabaseManager.init([serverUrl]);
+ const serverDatabaseAndOperator = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
+ database = serverDatabaseAndOperator.database;
+ operator = serverDatabaseAndOperator.operator;
+ });
+
+ afterEach(() => {
+ DatabaseManager.destroyServerDatabase(serverUrl);
+ jest.clearAllMocks();
+ });
+
+ it('should render correctly with no data', () => {
+ const props = getBaseProps();
+ const {getByTestId} = renderWithEverything(, {database});
+
+ const startARun = getByTestId('start-a-run');
+
+ // Default values from observables when no data exists
+ expect(startARun.props.currentUserId).toBe('');
+ expect(startARun.props.currentTeamId).toBe('');
+ });
+
+ it('should render correctly with both current user and team data', async () => {
+ await operator.handleSystem({
+ systems: [
+ {
+ id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID,
+ value: 'current-user-id',
+ },
+ {
+ id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID,
+ value: 'current-team-id',
+ },
+ ],
+ prepareRecordsOnly: false,
+ });
+
+ const props = getBaseProps();
+ const {getByTestId} = renderWithEverything(, {database});
+
+ const startARun = getByTestId('start-a-run');
+ expect(startARun.props.currentUserId).toBe('current-user-id');
+ expect(startARun.props.currentTeamId).toBe('current-team-id');
+ });
+
+ it('should update observables when data changes', async () => {
+ await operator.handleSystem({
+ systems: [
+ {
+ id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID,
+ value: 'current-user-id',
+ },
+ {
+ id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID,
+ value: 'current-team-id',
+ },
+ ],
+ prepareRecordsOnly: false,
+ });
+
+ const props = getBaseProps();
+ const {getByTestId} = renderWithEverything(, {database});
+
+ const startARun = getByTestId('start-a-run');
+ expect(startARun.props.currentUserId).toBe('current-user-id');
+ expect(startARun.props.currentTeamId).toBe('current-team-id');
+
+ await act(async () => {
+ // Update current user ID
+ await operator.handleSystem({
+ systems: [{
+ id: SYSTEM_IDENTIFIERS.CURRENT_USER_ID,
+ value: 'new-user-id',
+ }],
+ prepareRecordsOnly: false,
+ });
+ });
+
+ await waitFor(() => {
+ expect(startARun.props.currentUserId).toBe('new-user-id');
+ expect(startARun.props.currentTeamId).toBe('current-team-id');
+ });
+
+ await act(async () => {
+ // Update current team ID
+ await operator.handleSystem({
+ systems: [{
+ id: SYSTEM_IDENTIFIERS.CURRENT_TEAM_ID,
+ value: 'new-team-id',
+ }],
+ prepareRecordsOnly: false,
+ });
+ });
+
+ await waitFor(() => {
+ expect(startARun.props.currentUserId).toBe('new-user-id');
+ expect(startARun.props.currentTeamId).toBe('new-team-id');
+ });
+ });
+});
+
diff --git a/app/products/playbooks/screens/start_a_run/start_a_run.test.tsx b/app/products/playbooks/screens/start_a_run/start_a_run.test.tsx
new file mode 100644
index 000000000..8bbf1c2a2
--- /dev/null
+++ b/app/products/playbooks/screens/start_a_run/start_a_run.test.tsx
@@ -0,0 +1,776 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+/* eslint-disable max-lines */
+
+import {act, waitFor} from '@testing-library/react-native';
+import React, {type ComponentProps} from 'react';
+
+import CompassIcon from '@components/compass_icon';
+import FloatingAutocompleteSelector from '@components/floating_input/floating_autocomplete_selector';
+import FloatingTextInput from '@components/floating_input/floating_text_input_label';
+import OptionItem from '@components/option_item';
+import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
+import useNavButtonPressed from '@hooks/navigation_button_pressed';
+import {createPlaybookRun} from '@playbooks/actions/remote/runs';
+import {popTopScreen, setButtons} from '@screens/navigation';
+import {renderWithIntlAndTheme} from '@test/intl-test-helper';
+import TestHelper from '@test/test_helper';
+
+import StartARun from './start_a_run';
+
+jest.mock('@components/compass_icon', () => ({
+ __esModule: true,
+ default: jest.fn(),
+}));
+jest.mocked(CompassIcon).getImageSource = jest.fn().mockResolvedValue({uri: 'close-icon'});
+
+jest.mock('@components/floating_input/floating_text_input_label', () => ({
+ __esModule: true,
+ default: jest.fn(),
+}));
+jest.mocked(FloatingTextInput).mockImplementation((props) => React.createElement('FloatingTextInput', {testID: props.testID, ...props}));
+
+jest.mock('@components/floating_input/floating_autocomplete_selector', () => ({
+ __esModule: true,
+ default: jest.fn(),
+}));
+jest.mocked(FloatingAutocompleteSelector).mockImplementation((props) => React.createElement('FloatingAutocompleteSelector', props));
+
+jest.mock('@components/option_item', () => ({
+ __esModule: true,
+ default: jest.fn(),
+}));
+jest.mocked(OptionItem).mockImplementation((props) => React.createElement('OptionItem', props));
+
+jest.mock('@context/server', () => ({
+ useServerUrl: jest.fn(() => 'https://server-url.com'),
+}));
+
+jest.mock('@hooks/navigation_button_pressed', () => ({
+ __esModule: true,
+ default: jest.fn(),
+}));
+
+jest.mock('@hooks/android_back_handler', () => ({
+ __esModule: true,
+ default: jest.fn(),
+}));
+
+jest.mock('@playbooks/actions/remote/runs', () => ({
+ createPlaybookRun: jest.fn(),
+}));
+
+jest.mock('@screens/navigation', () => ({
+ popTopScreen: jest.fn(),
+ setButtons: jest.fn(),
+}));
+
+jest.mock('@utils/snack_bar', () => ({
+ showPlaybookErrorSnackbar: jest.fn(),
+}));
+
+function getLastCall(mock: jest.Mock): U {
+ const allCalls = mock.mock.calls;
+ return allCalls[allCalls.length - 1];
+}
+
+function getLastCallForButton(mock: jest.Mock, buttonId: string): U {
+ const allCalls = mock.mock.calls;
+ const buttonCalls = allCalls.filter((call) => call[0] === buttonId);
+ return buttonCalls[buttonCalls.length - 1];
+}
+
+describe('StartARun', () => {
+ function getBaseProps(): ComponentProps {
+ return {
+ componentId: 'PlaybooksStartARun',
+ playbook: TestHelper.fakePlaybook({
+ id: 'playbook-id',
+ title: 'Test Playbook',
+ }),
+ currentUserId: 'user-id',
+ currentTeamId: 'team-id',
+ onRunCreated: jest.fn(),
+ };
+ }
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ jest.mocked(CompassIcon.getImageSource).mockResolvedValue({uri: 'close-icon'});
+ jest.mocked(createPlaybookRun).mockResolvedValue({
+ data: TestHelper.fakePlaybookRun({id: 'run-id'}),
+ });
+ });
+
+ it('should render correctly with default props', () => {
+ const props = getBaseProps();
+ const {getByTestId} = renderWithIntlAndTheme();
+
+ expect(getByTestId('start_run.run_name_input')).toBeTruthy();
+ expect(getByTestId('start_run.run_description_input')).toBeTruthy();
+ expect(getByTestId('start_run.existing_channel_option')).toBeTruthy();
+ expect(getByTestId('start_run.new_channel_option')).toBeTruthy();
+ });
+
+ it('should initialize run name from playbook channel_name_template when channel_mode is create_new_channel', () => {
+ const props = getBaseProps();
+ props.playbook.channel_mode = 'create_new_channel';
+ props.playbook.channel_name_template = 'Template Name';
+
+ const {getByTestId} = renderWithIntlAndTheme();
+
+ const runNameInput = getByTestId('start_run.run_name_input');
+ expect(runNameInput).toHaveProp('value', 'Template Name');
+ });
+
+ it('should initialize run description from playbook run_summary_template when enabled', () => {
+ const props = getBaseProps();
+ props.playbook.run_summary_template_enabled = true;
+ props.playbook.run_summary_template = 'Template Description';
+
+ const {getByTestId} = renderWithIntlAndTheme();
+
+ const runDescriptionInput = getByTestId('start_run.run_description_input');
+ expect(runDescriptionInput).toHaveProp('value', 'Template Description');
+ });
+
+ it('should update run name when input changes', async () => {
+ const props = getBaseProps();
+ const {getByTestId} = renderWithIntlAndTheme();
+
+ const runNameInput = getByTestId('start_run.run_name_input');
+
+ await act(async () => {
+ runNameInput.props.onChangeText('New Run Name');
+ });
+
+ await waitFor(() => {
+ expect(getByTestId('start_run.run_name_input')).toHaveProp('value', 'New Run Name');
+ });
+ });
+
+ it('should update run description when input changes', async () => {
+ const props = getBaseProps();
+ const {getByTestId} = renderWithIntlAndTheme();
+
+ const runDescriptionInput = getByTestId('start_run.run_description_input');
+
+ expect(runDescriptionInput).toHaveProp('value', '');
+
+ await act(async () => {
+ runDescriptionInput.props.onChangeText('New Description');
+ });
+
+ await waitFor(() => {
+ expect(getByTestId('start_run.run_description_input')).toHaveProp('value', 'New Description');
+ });
+ });
+
+ it('should show existing channel selector when existing option is selected', async () => {
+ const props = getBaseProps();
+ const {getByTestId, queryByTestId} = renderWithIntlAndTheme();
+
+ // Existing channel option is selected by default, so let's unmark it first
+ const newChannelOption = getByTestId('start_run.new_channel_option');
+ await act(async () => {
+ newChannelOption.props.action();
+ });
+
+ await waitFor(() => {
+ expect(queryByTestId('start_run.existing_channel_selector')).toBeNull();
+ });
+
+ const existingOption = getByTestId('start_run.existing_channel_option');
+
+ await act(async () => {
+ existingOption.props.action();
+ });
+
+ await waitFor(() => {
+ expect(getByTestId('start_run.existing_channel_selector')).toBeTruthy();
+ });
+ });
+
+ it('should show private and public channel options when new channel option is selected', async () => {
+ const props = getBaseProps();
+ const {getByTestId, queryByTestId} = renderWithIntlAndTheme();
+
+ expect(queryByTestId('start_run.new_channel_public_option')).toBeNull();
+ expect(queryByTestId('start_run.new_channel_private_option')).toBeNull();
+
+ const newChannelOption = getByTestId('start_run.new_channel_option');
+
+ await act(async () => {
+ newChannelOption.props.action();
+ });
+
+ await waitFor(() => {
+ expect(getByTestId('start_run.new_channel_public_option')).toBeTruthy();
+ expect(getByTestId('start_run.new_channel_private_option')).toBeTruthy();
+ });
+ });
+
+ it('should select public channel option when clicked', async () => {
+ const props = getBaseProps();
+ const {getByTestId} = renderWithIntlAndTheme();
+
+ // First select new channel option
+ const newChannelOption = getByTestId('start_run.new_channel_option');
+ await act(async () => {
+ newChannelOption.props.action();
+ });
+
+ await waitFor(() => {
+ expect(getByTestId('start_run.new_channel_public_option')).toBeTruthy();
+ });
+
+ // Then select public channel
+ const publicOption = getByTestId('start_run.new_channel_public_option');
+ await act(async () => {
+ publicOption.props.action();
+ });
+
+ await waitFor(() => {
+ expect(getByTestId('start_run.new_channel_public_option')).toHaveProp('selected', true);
+ expect(getByTestId('start_run.new_channel_private_option')).toHaveProp('selected', false);
+ });
+ });
+
+ it('should select private channel option when clicked', async () => {
+ const props = getBaseProps();
+ const {getByTestId} = renderWithIntlAndTheme();
+
+ // First select new channel option
+ const newChannelOption = getByTestId('start_run.new_channel_option');
+ await act(async () => {
+ newChannelOption.props.action();
+ });
+
+ await waitFor(() => {
+ expect(getByTestId('start_run.new_channel_private_option')).toBeTruthy();
+ });
+
+ // Then select private channel
+ const privateOption = getByTestId('start_run.new_channel_private_option');
+ await act(async () => {
+ privateOption.props.action();
+ });
+
+ await waitFor(() => {
+ expect(getByTestId('start_run.new_channel_public_option')).toHaveProp('selected', false);
+ expect(getByTestId('start_run.new_channel_private_option')).toHaveProp('selected', true);
+ });
+ });
+
+ it('should disable create button when run name is empty', async () => {
+ const props = getBaseProps();
+ renderWithIntlAndTheme();
+
+ await waitFor(() => {
+ expect(setButtons).toHaveBeenCalled();
+ });
+
+ const lastCall = getLastCall(jest.mocked(setButtons));
+ const rightButton = lastCall[1]?.rightButtons?.[0];
+ expect(rightButton?.enabled).toBe(false);
+ });
+
+ it('should enable create button when run name is provided', async () => {
+ const props = getBaseProps();
+ const {getByTestId} = renderWithIntlAndTheme();
+
+ const runNameInput = getByTestId('start_run.run_name_input');
+ await act(async () => {
+ runNameInput.props.onChangeText('Test Run');
+ });
+
+ await waitFor(() => {
+ expect(setButtons).toHaveBeenCalled();
+ });
+
+ const lastCall = getLastCall(jest.mocked(setButtons));
+ const rightButton = lastCall[1]?.rightButtons?.[0];
+ expect(rightButton?.enabled).toBe(true);
+ });
+
+ it('should show error message when run name is empty', () => {
+ const props = getBaseProps();
+ const {getByTestId} = renderWithIntlAndTheme();
+
+ const runNameInput = getByTestId('start_run.run_name_input');
+ expect(runNameInput).toHaveProp('error', expect.any(String));
+ });
+
+ it('should not show error message when run name is provided', async () => {
+ const props = getBaseProps();
+ const {getByTestId} = renderWithIntlAndTheme();
+
+ const runNameInput = getByTestId('start_run.run_name_input');
+ await act(async () => {
+ runNameInput.props.onChangeText('Test Run');
+ });
+
+ await waitFor(() => {
+ expect(getByTestId('start_run.run_name_input').props.error).toBeUndefined();
+ });
+ });
+
+ it('should create run successfully with existing channel', async () => {
+ const props = getBaseProps();
+ const mockRun = TestHelper.fakePlaybookRun({id: 'run-id'});
+ jest.mocked(createPlaybookRun).mockResolvedValue({data: mockRun});
+
+ const {getByTestId} = renderWithIntlAndTheme();
+
+ const runNameInput = getByTestId('start_run.run_name_input');
+ await act(async () => {
+ runNameInput.props.onChangeText('Test Run');
+ });
+
+ await waitFor(() => {
+ expect(getByTestId('start_run.run_name_input')).toHaveProp('value', 'Test Run');
+ });
+
+ // Get the create button handler
+ const lastCall = getLastCallForButton(jest.mocked(useNavButtonPressed), 'create-run');
+ const createHandler = lastCall[2];
+
+ // Simulate create button press
+ await act(async () => {
+ createHandler();
+ });
+
+ await waitFor(() => {
+ expect(createPlaybookRun).toHaveBeenCalledWith(
+ 'https://server-url.com',
+ 'playbook-id',
+ 'user-id',
+ 'team-id',
+ 'Test Run',
+ '',
+ undefined,
+ undefined,
+ );
+ expect(popTopScreen).toHaveBeenCalledWith('PlaybooksStartARun');
+ expect(props.onRunCreated).toHaveBeenCalledWith(mockRun);
+ });
+ });
+
+ it('should create run with new channel and private setting', async () => {
+ const props = getBaseProps();
+ const mockRun = TestHelper.fakePlaybookRun({id: 'run-id'});
+ jest.mocked(createPlaybookRun).mockResolvedValue({data: mockRun});
+
+ const {getByTestId} = renderWithIntlAndTheme();
+
+ const runNameInput = getByTestId('start_run.run_name_input');
+ await act(async () => {
+ runNameInput.props.onChangeText('Test Run');
+ });
+
+ // Select new channel option
+ const newChannelOption = getByTestId('start_run.new_channel_option');
+ await act(async () => {
+ newChannelOption.props.action();
+ });
+
+ // Select public channel
+ await waitFor(() => {
+ expect(getByTestId('start_run.new_channel_public_option')).toBeTruthy();
+ });
+
+ const privateOption = getByTestId('start_run.new_channel_private_option');
+ await act(async () => {
+ privateOption.props.action();
+ });
+
+ // Verify the state is correct
+ await waitFor(() => {
+ expect(getByTestId('start_run.new_channel_private_option')).toHaveProp('selected', true);
+ });
+
+ // Get the create button handler
+ const lastCall = getLastCallForButton(jest.mocked(useNavButtonPressed), 'create-run');
+ const createHandler = lastCall[2];
+
+ // Simulate create button press
+ await act(async () => {
+ createHandler();
+ });
+
+ await waitFor(() => {
+ expect(createPlaybookRun).toHaveBeenCalledWith(
+ 'https://server-url.com',
+ 'playbook-id',
+ 'user-id',
+ 'team-id',
+ 'Test Run',
+ '',
+ undefined,
+ false, // createPublicRun
+ );
+ });
+ });
+
+ it('should create run with new channel and public setting', async () => {
+ const props = getBaseProps();
+ const mockRun = TestHelper.fakePlaybookRun({id: 'run-id'});
+ jest.mocked(createPlaybookRun).mockResolvedValue({data: mockRun});
+
+ const {getByTestId} = renderWithIntlAndTheme();
+
+ const runNameInput = getByTestId('start_run.run_name_input');
+ await act(async () => {
+ runNameInput.props.onChangeText('Test Run');
+ });
+
+ // Select new channel option
+ const newChannelOption = getByTestId('start_run.new_channel_option');
+ await act(async () => {
+ newChannelOption.props.action();
+ });
+
+ // Select public channel
+ await waitFor(() => {
+ expect(getByTestId('start_run.new_channel_public_option')).toBeTruthy();
+ });
+
+ const publicOption = getByTestId('start_run.new_channel_public_option');
+ await act(async () => {
+ publicOption.props.action();
+ });
+
+ // Verify the state is correct
+ await waitFor(() => {
+ expect(getByTestId('start_run.new_channel_public_option')).toHaveProp('selected', true);
+ });
+
+ // Get the create button handler
+ const lastCall = getLastCallForButton(jest.mocked(useNavButtonPressed), 'create-run');
+ const createHandler = lastCall[2];
+
+ // Simulate create button press
+ await act(async () => {
+ createHandler();
+ });
+
+ await waitFor(() => {
+ expect(createPlaybookRun).toHaveBeenCalledWith(
+ 'https://server-url.com',
+ 'playbook-id',
+ 'user-id',
+ 'team-id',
+ 'Test Run',
+ '',
+ undefined,
+ true, // createPublicRun
+ );
+ });
+ });
+
+ it('should handle createPlaybookRun error', async () => {
+ const props = getBaseProps();
+ const {showPlaybookErrorSnackbar} = require('@utils/snack_bar');
+ jest.mocked(createPlaybookRun).mockResolvedValue({
+ error: {message: 'Error creating run'},
+ });
+
+ const {getByTestId} = renderWithIntlAndTheme();
+
+ const runNameInput = getByTestId('start_run.run_name_input');
+ await act(async () => {
+ runNameInput.props.onChangeText('Test Run');
+ });
+
+ // Get the create button handler
+ const lastCall = getLastCallForButton(jest.mocked(useNavButtonPressed), 'create-run');
+ const createHandler = lastCall[2];
+
+ // Simulate create button press
+ await act(async () => {
+ createHandler();
+ });
+
+ await waitFor(() => {
+ expect(createPlaybookRun).toHaveBeenCalled();
+ expect(showPlaybookErrorSnackbar).toHaveBeenCalled();
+ expect(popTopScreen).not.toHaveBeenCalled();
+ expect(props.onRunCreated).not.toHaveBeenCalled();
+ });
+ });
+
+ it('should trim run name and description when creating run', async () => {
+ const props = getBaseProps();
+ const mockRun = TestHelper.fakePlaybookRun({id: 'run-id'});
+ jest.mocked(createPlaybookRun).mockResolvedValue({data: mockRun});
+
+ const {getByTestId} = renderWithIntlAndTheme();
+
+ const runNameInput = getByTestId('start_run.run_name_input');
+ const runDescriptionInput = getByTestId('start_run.run_description_input');
+
+ await act(async () => {
+ runNameInput.props.onChangeText(' Test Run ');
+ runDescriptionInput.props.onChangeText(' Test Description ');
+ });
+
+ // Get the create button handler
+ const lastCall = getLastCallForButton(jest.mocked(useNavButtonPressed), 'create-run');
+ const createHandler = lastCall[2];
+
+ // Simulate create button press
+ await act(async () => {
+ createHandler();
+ });
+
+ // Verify trimming happens in the API call
+ await waitFor(() => {
+ expect(createPlaybookRun).toHaveBeenCalledWith(
+ 'https://server-url.com',
+ 'playbook-id',
+ 'user-id',
+ 'team-id',
+ 'Test Run', // trimmed
+ 'Test Description', // trimmed
+ undefined,
+ undefined,
+ );
+ });
+ });
+
+ it('should handle close button press', () => {
+ const props = getBaseProps();
+ renderWithIntlAndTheme();
+
+ // Get the close button handler
+ const lastCall = getLastCallForButton(jest.mocked(useNavButtonPressed), 'close-start-a-run');
+ const closeHandler = lastCall[2];
+
+ act(() => {
+ closeHandler();
+ });
+
+ expect(popTopScreen).toHaveBeenCalledWith('PlaybooksStartARun');
+ });
+
+ it('should handle Android back button press', () => {
+ const props = getBaseProps();
+ renderWithIntlAndTheme();
+
+ expect(useAndroidHardwareBackHandler).toHaveBeenCalledWith(
+ 'PlaybooksStartARun',
+ expect.any(Function),
+ );
+
+ // Get the back handler
+ const backHandler = jest.mocked(useAndroidHardwareBackHandler).mock.calls[0][1];
+
+ act(() => {
+ backHandler();
+ });
+
+ expect(popTopScreen).toHaveBeenCalledWith('PlaybooksStartARun');
+ });
+
+ it('should create run with existing channel and channel ID', async () => {
+ const props = getBaseProps();
+ const mockRun = TestHelper.fakePlaybookRun({id: 'run-id'});
+ jest.mocked(createPlaybookRun).mockResolvedValue({data: mockRun});
+
+ const {getByTestId} = renderWithIntlAndTheme();
+
+ const runNameInput = getByTestId('start_run.run_name_input');
+ await act(async () => {
+ runNameInput.props.onChangeText('Test Run');
+ });
+
+ // Select existing channel option
+ const existingOption = getByTestId('start_run.existing_channel_option');
+ await act(async () => {
+ existingOption.props.action();
+ });
+
+ await waitFor(() => {
+ expect(getByTestId('start_run.existing_channel_selector')).toBeTruthy();
+ });
+
+ // Simulate channel selection
+ const channelSelector = getByTestId('start_run.existing_channel_selector');
+ await act(async () => {
+ channelSelector.props.onSelected({value: 'channel-id'});
+ });
+
+ // Get the create button handler
+ const lastCall = getLastCallForButton(jest.mocked(useNavButtonPressed), 'create-run');
+ const createHandler = lastCall[2];
+
+ // Simulate create button press
+ await act(async () => {
+ createHandler();
+ });
+
+ await waitFor(() => {
+ expect(createPlaybookRun).toHaveBeenCalledWith(
+ 'https://server-url.com',
+ 'playbook-id',
+ 'user-id',
+ 'team-id',
+ 'Test Run',
+ '',
+ 'channel-id',
+ undefined,
+ );
+ });
+ });
+
+ it('should call setButtons with correct button configuration', async () => {
+ const props = getBaseProps();
+ renderWithIntlAndTheme();
+
+ await waitFor(() => {
+ expect(setButtons).toHaveBeenCalled();
+ });
+
+ const lastCall = getLastCall(jest.mocked(setButtons));
+ expect(lastCall[0]).toBe('PlaybooksStartARun');
+ expect(lastCall[1]?.leftButtons).toBeDefined();
+ expect(lastCall[1]?.rightButtons).toBeDefined();
+ expect(lastCall[1]?.leftButtons?.[0]?.id).toBe('close-start-a-run');
+ expect(lastCall[1]?.rightButtons?.[0]?.id).toBe('create-run');
+ });
+
+ it('should hide existing channel selector when new channel option is selected', async () => {
+ const props = getBaseProps();
+ const {getByTestId, queryByTestId} = renderWithIntlAndTheme();
+
+ // Select existing option first
+ const existingOption = getByTestId('start_run.existing_channel_option');
+ await act(async () => {
+ existingOption.props.action();
+ });
+
+ await waitFor(() => {
+ expect(getByTestId('start_run.existing_channel_selector')).toBeTruthy();
+ });
+
+ // Select new channel option
+ const newChannelOption = getByTestId('start_run.new_channel_option');
+ await act(async () => {
+ newChannelOption.props.action();
+ });
+
+ await waitFor(() => {
+ expect(queryByTestId('start_run.existing_channel_selector')).toBeNull();
+ expect(getByTestId('start_run.new_channel_public_option')).toBeTruthy();
+ });
+ });
+
+ it('should hide new channel options when existing channel option is selected', async () => {
+ const props = getBaseProps();
+ const {getByTestId, queryByTestId} = renderWithIntlAndTheme();
+
+ // Select new channel option first
+ const newChannelOption = getByTestId('start_run.new_channel_option');
+ await act(async () => {
+ newChannelOption.props.action();
+ });
+
+ await waitFor(() => {
+ expect(getByTestId('start_run.new_channel_public_option')).toBeTruthy();
+ });
+
+ // Select existing channel option
+ const existingOption = getByTestId('start_run.existing_channel_option');
+ await act(async () => {
+ existingOption.props.action();
+ });
+
+ await waitFor(() => {
+ expect(queryByTestId('start_run.new_channel_public_option')).toBeNull();
+ expect(getByTestId('start_run.existing_channel_selector')).toBeTruthy();
+ });
+ });
+
+ it('should default to existing channel option', () => {
+ const props = getBaseProps();
+ const {getByTestId} = renderWithIntlAndTheme();
+
+ expect(getByTestId('start_run.existing_channel_option')).toHaveProp('selected', true);
+ expect(getByTestId('start_run.new_channel_option')).toHaveProp('selected', false);
+ });
+
+ it('should default to private channel when new channel is selected', async () => {
+ const props = getBaseProps();
+ const {getByTestId} = renderWithIntlAndTheme();
+
+ // Select new channel option
+ const newChannelOption = getByTestId('start_run.new_channel_option');
+ await act(async () => {
+ newChannelOption.props.action();
+ });
+
+ await waitFor(() => {
+ expect(getByTestId('start_run.new_channel_public_option')).toHaveProp('selected', false);
+ expect(getByTestId('start_run.new_channel_private_option')).toHaveProp('selected', true);
+ });
+ });
+
+ it('should ignore multiselect case', async () => {
+ const props = getBaseProps();
+ const {getByTestId} = renderWithIntlAndTheme();
+
+ const channelSelector = getByTestId('start_run.existing_channel_selector');
+
+ await act(async () => {
+ // Set some initial value
+ channelSelector.props.onSelected({value: 'valid-id', text: 'Valid Channel'});
+ });
+
+ await waitFor(() => {
+ expect(channelSelector).toHaveProp('selected', 'valid-id');
+ });
+
+ await act(async () => {
+ channelSelector.props.onSelected([{value: 'channel-id', text: 'Channel 1'}, {value: 'channel-id-2', text: 'Channel 2'}]);
+ });
+
+ await waitFor(() => {
+ expect(channelSelector).toHaveProp('selected', 'valid-id');
+ });
+ });
+
+ it('should ignore empty value case', async () => {
+ const props = getBaseProps();
+ const {getByTestId} = renderWithIntlAndTheme();
+
+ const channelSelector = getByTestId('start_run.existing_channel_selector');
+
+ await act(async () => {
+ // Set some initial value
+ channelSelector.props.onSelected({value: 'valid-id', text: 'Valid Channel'});
+ });
+
+ await waitFor(() => {
+ expect(channelSelector).toHaveProp('selected', 'valid-id');
+ });
+
+ await act(async () => {
+ channelSelector.props.onSelected();
+ });
+
+ await waitFor(() => {
+ expect(channelSelector).toHaveProp('selected', 'valid-id');
+ });
+ });
+
+ it('should default to existing channel option when channel id is provided', () => {
+ const props = getBaseProps();
+ props.channelId = 'channel-id';
+ const {getByTestId} = renderWithIntlAndTheme();
+ expect(getByTestId('start_run.existing_channel_option')).toHaveProp('selected', true);
+ expect(getByTestId('start_run.new_channel_option')).toHaveProp('selected', false);
+ expect(getByTestId('start_run.existing_channel_selector')).toHaveProp('selected', 'channel-id');
+ });
+});
+
diff --git a/app/products/playbooks/screens/start_a_run/start_a_run.tsx b/app/products/playbooks/screens/start_a_run/start_a_run.tsx
index f0f80b084..1a9c6d7c4 100644
--- a/app/products/playbooks/screens/start_a_run/start_a_run.tsx
+++ b/app/products/playbooks/screens/start_a_run/start_a_run.tsx
@@ -96,13 +96,13 @@ function StartARun({
const [runName, setRunName] = useState(() => {
if (playbook?.channel_mode === 'create_new_channel') {
- return playbook.channel_name_template || '';
+ return playbook.channel_name_template;
}
return '';
});
const [runDescription, setRunDescription] = useState(() => {
if (playbook?.run_summary_template_enabled) {
- return playbook.run_summary_template || '';
+ return playbook.run_summary_template;
}
return '';
});
@@ -113,10 +113,6 @@ function StartARun({
const canSave = Boolean(runName.trim());
const handleStartRun = useCallback(async () => {
- if (!runName.trim()) {
- return;
- }
-
const res = await createPlaybookRun(serverUrl, playbook.id, currentUserId, currentTeamId, runName.trim(), runDescription.trim(), selectedChannelId, channelOption === 'new' ? createPublicChannel : undefined);
if (res.error || !res.data) {
logDebug('error on createPlaybookRun', getFullErrorMessage(res.error));
diff --git a/app/products/playbooks/types/post.ts b/app/products/playbooks/types/post.ts
new file mode 100644
index 000000000..077a90787
--- /dev/null
+++ b/app/products/playbooks/types/post.ts
@@ -0,0 +1,11 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+export type PostStatusUpdateProps = {
+ authorUsername: string;
+ numTasks: number;
+ numTasksChecked: number;
+ participantIds: string[];
+ playbookRunId: string;
+ runName: string;
+};
diff --git a/app/products/playbooks/utils/progress.test.ts b/app/products/playbooks/utils/progress.test.ts
index 6bb5b6b0d..0c781d1f1 100644
--- a/app/products/playbooks/utils/progress.test.ts
+++ b/app/products/playbooks/utils/progress.test.ts
@@ -30,6 +30,20 @@ describe('progress utils', () => {
expect(result.totalNumber).toBe(2);
});
+ it('should handle database model items', () => {
+ const items = [
+ TestHelper.fakePlaybookChecklistItemModel({state: ''}),
+ TestHelper.fakePlaybookChecklistItemModel({state: 'in_progress'}),
+ ];
+
+ const result = getChecklistProgress(items);
+
+ expect(result.progress).toBe(0);
+ expect(result.skipped).toBe(false);
+ expect(result.completed).toBe(0);
+ expect(result.totalNumber).toBe(2);
+ });
+
it('should return 100 progress for all completed items', () => {
const items = [
TestHelper.fakePlaybookChecklistItem('checklist-id', {state: 'closed'}),
diff --git a/app/products/playbooks/utils/run.test.ts b/app/products/playbooks/utils/run.test.ts
index 35b8f5c02..249897091 100644
--- a/app/products/playbooks/utils/run.test.ts
+++ b/app/products/playbooks/utils/run.test.ts
@@ -3,7 +3,7 @@
import TestHelper from '@test/test_helper';
-import {getRunScheduledTimestamp, isRunFinished, getMaxRunUpdateAt, isOverdue, isDueSoon, isPending} from './run';
+import {getRunScheduledTimestamp, isRunFinished, getMaxRunUpdateAt, isOverdue, isDueSoon, isPending, isOutstanding} from './run';
describe('run utils', () => {
describe('getRunScheduledTimestamp', () => {
@@ -368,4 +368,47 @@ describe('run utils', () => {
expect(isPending(item)).toBe(true);
});
});
+
+ describe('isOutstanding', () => {
+ it('should return true for open items', () => {
+ const item = TestHelper.fakePlaybookChecklistItem('checklist-id', {
+ state: '',
+ });
+
+ expect(isOutstanding(item)).toBe(true);
+ });
+
+ it('should return true for in_progress items', () => {
+ const item = TestHelper.fakePlaybookChecklistItem('checklist-id', {
+ state: 'in_progress',
+ });
+
+ expect(isOutstanding(item)).toBe(true);
+ });
+
+ it('should return false for closed items', () => {
+ const item = TestHelper.fakePlaybookChecklistItem('checklist-id', {
+ state: 'closed',
+ });
+
+ expect(isOutstanding(item)).toBe(false);
+ });
+
+ it('should return false for skipped items', () => {
+ const item = TestHelper.fakePlaybookChecklistItem('checklist-id', {
+ state: 'skipped',
+ });
+
+ expect(isOutstanding(item)).toBe(false);
+ });
+
+ it('should handle database model with different property name', () => {
+ const item = TestHelper.fakePlaybookChecklistItemModel({
+ id: 'checklist-id',
+ state: '',
+ });
+
+ expect(isOutstanding(item)).toBe(true);
+ });
+ });
});
diff --git a/app/products/playbooks/utils/types.test.ts b/app/products/playbooks/utils/types.test.ts
new file mode 100644
index 000000000..adb9339d4
--- /dev/null
+++ b/app/products/playbooks/utils/types.test.ts
@@ -0,0 +1,310 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {isPostStatusUpdateProps} from './types';
+
+describe('isPostStatusUpdateProps', () => {
+ describe('valid props', () => {
+ it('should return true for valid PostStatusUpdateProps', () => {
+ const props = {
+ authorUsername: 'test-user',
+ numTasks: 5,
+ numTasksChecked: 3,
+ participantIds: ['user1', 'user2', 'user3'],
+ playbookRunId: 'run-123',
+ runName: 'Test Run',
+ };
+
+ expect(isPostStatusUpdateProps(props)).toBe(true);
+ });
+
+ it('should return true with empty participantIds array', () => {
+ const props = {
+ authorUsername: 'test-user',
+ numTasks: 0,
+ numTasksChecked: 0,
+ participantIds: [],
+ playbookRunId: 'run-123',
+ runName: 'Test Run',
+ };
+
+ expect(isPostStatusUpdateProps(props)).toBe(true);
+ });
+
+ it('should return true with single participant', () => {
+ const props = {
+ authorUsername: 'test-user',
+ numTasks: 1,
+ numTasksChecked: 1,
+ participantIds: ['user1'],
+ playbookRunId: 'run-123',
+ runName: 'Test Run',
+ };
+
+ expect(isPostStatusUpdateProps(props)).toBe(true);
+ });
+ });
+
+ describe('invalid props - non-object values', () => {
+ it('should return false for null', () => {
+ expect(isPostStatusUpdateProps(null)).toBe(false);
+ });
+
+ it('should return false for undefined', () => {
+ expect(isPostStatusUpdateProps(undefined)).toBe(false);
+ });
+
+ it('should return false for string', () => {
+ expect(isPostStatusUpdateProps('not an object')).toBe(false);
+ });
+
+ it('should return false for number', () => {
+ expect(isPostStatusUpdateProps(123)).toBe(false);
+ });
+
+ it('should return false for boolean', () => {
+ expect(isPostStatusUpdateProps(true)).toBe(false);
+ });
+
+ it('should return false for array', () => {
+ expect(isPostStatusUpdateProps([])).toBe(false);
+ });
+ });
+
+ describe('invalid props - missing properties', () => {
+ it('should return false when authorUsername is missing', () => {
+ const props = {
+ numTasks: 5,
+ numTasksChecked: 3,
+ participantIds: ['user1'],
+ playbookRunId: 'run-123',
+ runName: 'Test Run',
+ };
+
+ expect(isPostStatusUpdateProps(props)).toBe(false);
+ });
+
+ it('should return false when numTasks is missing', () => {
+ const props = {
+ authorUsername: 'test-user',
+ numTasksChecked: 3,
+ participantIds: ['user1'],
+ playbookRunId: 'run-123',
+ runName: 'Test Run',
+ };
+
+ expect(isPostStatusUpdateProps(props)).toBe(false);
+ });
+
+ it('should return false when numTasksChecked is missing', () => {
+ const props = {
+ authorUsername: 'test-user',
+ numTasks: 5,
+ participantIds: ['user1'],
+ playbookRunId: 'run-123',
+ runName: 'Test Run',
+ };
+
+ expect(isPostStatusUpdateProps(props)).toBe(false);
+ });
+
+ it('should return false when participantIds is missing', () => {
+ const props = {
+ authorUsername: 'test-user',
+ numTasks: 5,
+ numTasksChecked: 3,
+ playbookRunId: 'run-123',
+ runName: 'Test Run',
+ };
+
+ expect(isPostStatusUpdateProps(props)).toBe(false);
+ });
+
+ it('should return false when playbookRunId is missing', () => {
+ const props = {
+ authorUsername: 'test-user',
+ numTasks: 5,
+ numTasksChecked: 3,
+ participantIds: ['user1'],
+ runName: 'Test Run',
+ };
+
+ expect(isPostStatusUpdateProps(props)).toBe(false);
+ });
+
+ it('should return false when runName is missing', () => {
+ const props = {
+ authorUsername: 'test-user',
+ numTasks: 5,
+ numTasksChecked: 3,
+ participantIds: ['user1'],
+ playbookRunId: 'run-123',
+ };
+
+ expect(isPostStatusUpdateProps(props)).toBe(false);
+ });
+ });
+
+ describe('invalid props - wrong property types', () => {
+ it('should return false when authorUsername is not a string', () => {
+ const props = {
+ authorUsername: 123,
+ numTasks: 5,
+ numTasksChecked: 3,
+ participantIds: ['user1'],
+ playbookRunId: 'run-123',
+ runName: 'Test Run',
+ };
+
+ expect(isPostStatusUpdateProps(props)).toBe(false);
+ });
+
+ it('should return false when numTasks is not a number', () => {
+ const props = {
+ authorUsername: 'test-user',
+ numTasks: '5',
+ numTasksChecked: 3,
+ participantIds: ['user1'],
+ playbookRunId: 'run-123',
+ runName: 'Test Run',
+ };
+
+ expect(isPostStatusUpdateProps(props)).toBe(false);
+ });
+
+ it('should return false when numTasksChecked is not a number', () => {
+ const props = {
+ authorUsername: 'test-user',
+ numTasks: 5,
+ numTasksChecked: '3',
+ participantIds: ['user1'],
+ playbookRunId: 'run-123',
+ runName: 'Test Run',
+ };
+
+ expect(isPostStatusUpdateProps(props)).toBe(false);
+ });
+
+ it('should return false when participantIds is not an array', () => {
+ const props = {
+ authorUsername: 'test-user',
+ numTasks: 5,
+ numTasksChecked: 3,
+ participantIds: 'not-an-array',
+ playbookRunId: 'run-123',
+ runName: 'Test Run',
+ };
+
+ expect(isPostStatusUpdateProps(props)).toBe(false);
+ });
+
+ it('should return false when participantIds contains non-string elements', () => {
+ const props = {
+ authorUsername: 'test-user',
+ numTasks: 5,
+ numTasksChecked: 3,
+ participantIds: ['user1', 123, 'user3'],
+ playbookRunId: 'run-123',
+ runName: 'Test Run',
+ };
+
+ expect(isPostStatusUpdateProps(props)).toBe(false);
+ });
+
+ it('should return false when participantIds contains null', () => {
+ const props = {
+ authorUsername: 'test-user',
+ numTasks: 5,
+ numTasksChecked: 3,
+ participantIds: ['user1', null, 'user3'],
+ playbookRunId: 'run-123',
+ runName: 'Test Run',
+ };
+
+ expect(isPostStatusUpdateProps(props)).toBe(false);
+ });
+
+ it('should return false when playbookRunId is not a string', () => {
+ const props = {
+ authorUsername: 'test-user',
+ numTasks: 5,
+ numTasksChecked: 3,
+ participantIds: ['user1'],
+ playbookRunId: 123,
+ runName: 'Test Run',
+ };
+
+ expect(isPostStatusUpdateProps(props)).toBe(false);
+ });
+
+ it('should return false when runName is not a string', () => {
+ const props = {
+ authorUsername: 'test-user',
+ numTasks: 5,
+ numTasksChecked: 3,
+ participantIds: ['user1'],
+ playbookRunId: 'run-123',
+ runName: 123,
+ };
+
+ expect(isPostStatusUpdateProps(props)).toBe(false);
+ });
+ });
+
+ describe('edge cases', () => {
+ it('should return false for empty string authorUsername', () => {
+ const props = {
+ authorUsername: '',
+ numTasks: 5,
+ numTasksChecked: 3,
+ participantIds: ['user1'],
+ playbookRunId: 'run-123',
+ runName: 'Test Run',
+ };
+
+ // Empty string is still a string, so it should pass type check
+ expect(isPostStatusUpdateProps(props)).toBe(true);
+ });
+
+ it('should return false when participantIds contains objects', () => {
+ const props = {
+ authorUsername: 'test-user',
+ numTasks: 5,
+ numTasksChecked: 3,
+ participantIds: ['user1', {id: 'user2'}, 'user3'],
+ playbookRunId: 'run-123',
+ runName: 'Test Run',
+ };
+
+ expect(isPostStatusUpdateProps(props)).toBe(false);
+ });
+
+ it('should return false when participantIds contains arrays', () => {
+ const props = {
+ authorUsername: 'test-user',
+ numTasks: 5,
+ numTasksChecked: 3,
+ participantIds: ['user1', ['user2'], 'user3'],
+ playbookRunId: 'run-123',
+ runName: 'Test Run',
+ };
+
+ expect(isPostStatusUpdateProps(props)).toBe(false);
+ });
+
+ it('should handle object with extra properties', () => {
+ const props = {
+ authorUsername: 'test-user',
+ numTasks: 5,
+ numTasksChecked: 3,
+ participantIds: ['user1'],
+ playbookRunId: 'run-123',
+ runName: 'Test Run',
+ extraProperty: 'should be ignored',
+ };
+
+ expect(isPostStatusUpdateProps(props)).toBe(true);
+ });
+ });
+});
+
diff --git a/app/products/playbooks/utils/types.ts b/app/products/playbooks/utils/types.ts
new file mode 100644
index 000000000..f6c37d633
--- /dev/null
+++ b/app/products/playbooks/utils/types.ts
@@ -0,0 +1,40 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import type {PostStatusUpdateProps} from '@playbooks/types/post';
+
+export function isPostStatusUpdateProps(props: unknown): props is PostStatusUpdateProps {
+ if (typeof props !== 'object' || props === null) {
+ return false;
+ }
+
+ if (!('authorUsername' in props) || typeof props.authorUsername !== 'string') {
+ return false;
+ }
+
+ if (!('numTasks' in props) || typeof props.numTasks !== 'number') {
+ return false;
+ }
+
+ if (!('numTasksChecked' in props) || typeof props.numTasksChecked !== 'number') {
+ return false;
+ }
+
+ if (!('participantIds' in props) || !Array.isArray(props.participantIds)) {
+ return false;
+ }
+
+ if (!props.participantIds.every((id) => typeof id === 'string')) {
+ return false;
+ }
+
+ if (!('playbookRunId' in props) || typeof props.playbookRunId !== 'string') {
+ return false;
+ }
+
+ if (!('runName' in props) || typeof props.runName !== 'string') {
+ return false;
+ }
+
+ return true;
+}
diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json
index ea48506c3..902cc9416 100644
--- a/assets/base/i18n/en.json
+++ b/assets/base/i18n/en.json
@@ -988,8 +988,6 @@
"playbook_run.out_of_date_header.message": "Unable to connect to server. Content may be out of date. Last updated {lastUpdated}.",
"playbook.last_updated": "Last update {date}",
"playbook.participants": "Run Participants",
- "playbook.runs.finished": "Finished",
- "playbook.runs.in-progress": "In Progress",
"playbooks.checklist_item.assignee": "Assignee",
"playbooks.checklist_item.check": "Check",
"playbooks.checklist_item.checked": "Checked",
@@ -1021,10 +1019,6 @@
"playbooks.only_runs_available.description": "Only Playbook Runs are available on mobile. To access the Playbook, please use the desktop or web app.",
"playbooks.only_runs_available.ok": "OK",
"playbooks.only_runs_available.title": "Playbooks not available",
- "playbooks.participant_playbooks.cached_warning_message": "Showing cached data only. Some playbook runs or updates may be missing from this list.",
- "playbooks.participant_playbooks.cached_warning_title": "Cannot reach the server",
- "playbooks.participant_playbooks.tab_finished": "Finished",
- "playbooks.participant_playbooks.tab_in_progress": "In Progress",
"playbooks.participant_playbooks.title": "Playbook runs",
"playbooks.playbook_run.error.description": "Please check your network connection or try again later.",
"playbooks.playbook_run.error.title": "Unable to fetch run details",
@@ -1072,6 +1066,10 @@
"playbooks.row.never_used": "Never used",
"playbooks.row.no_runs": "No runs in progress",
"playbooks.row.runs": "{count} {count, plural, one {run} other {runs}} in progress",
+ "playbooks.run_list.cached_warning_message": "Showing cached data only. Some playbook runs or updates may be missing from this list.",
+ "playbooks.run_list.cached_warning_title": "Cannot reach the server",
+ "playbooks.run_list.tab_finished": "Finished",
+ "playbooks.run_list.tab_in_progress": "In Progress",
"playbooks.runs.finished.description": "When a run in this channel finishes, you’ll see it here.",
"playbooks.runs.finished.title": "No finished runs",
"playbooks.runs.in_progress.description": "When a run starts in this channel, you’ll see it here.",
@@ -1097,6 +1095,7 @@
"playbooks.start_run.run_name_error": "Please add a name for this run",
"playbooks.start_run.run_name_label": "Run name",
"playbooks.start_run.run_name_placeholder": "Add a name for your run",
+ "playbooks.status_update_post.invalid_status_update_props": "Playbooks status update post with invalid properties",
"playbooks.status_update_post.num_tasks": "**{numTasksChecked, number}** of **{numTasks, number}** {numTasks, plural, =1 {task} other {tasks}} checked",
"playbooks.status_update_post.participants": "{numParticipants, number} {numParticipants, plural, =1 {participant} other {participants}}",
"playbooks.status_update_post.update": "@{authorUsername} posted an update for [{runName}]({link})",
diff --git a/test/test_helper.ts b/test/test_helper.ts
index 7ec7a4a84..2d5879de6 100644
--- a/test/test_helper.ts
+++ b/test/test_helper.ts
@@ -1180,6 +1180,33 @@ class TestHelperSingleton {
return playbookRuns;
};
+ fakePlaybook = (overwrite: Partial = {}): Playbook => {
+ return {
+ id: this.generateId(),
+ title: 'Test Playbook',
+ description: 'Test Description',
+ team_id: this.basicTeam?.id || '',
+ create_public_playbook_run: false,
+ delete_at: 0,
+ run_summary_template_enabled: false,
+ run_summary_template: '',
+ channel_name_template: '',
+ channel_mode: '',
+ public: false,
+ default_owner_id: '',
+ default_owner_enabled: false,
+ num_stages: 0,
+ num_steps: 0,
+ num_runs: 0,
+ num_actions: 0,
+ last_run_at: 0,
+ members: [],
+ default_playbook_member_role: '',
+ active_runs: 0,
+ ...overwrite,
+ };
+ };
+
fakePlaybookRun = (overwrite: Partial = {}): PlaybookRun => {
const run = this.createPlaybookRuns(1, 0, 0);
@@ -1207,6 +1234,18 @@ class TestHelperSingleton {
};
};
+ fakePlaybookRunMetadata = (overwrite: Partial = {}): PlaybookRunMetadata => {
+ return {
+ channel_name: 'channel-name',
+ channel_display_name: 'Channel Display Name',
+ team_name: 'team-name',
+ num_participants: 5,
+ total_posts: 10,
+ followers: ['user-id-1', 'user-id-2'],
+ ...overwrite,
+ };
+ };
+
fakePlaybookRunModel = (overwrite: Partial = {}): PlaybookRunModel => {
return {
...this.fakeModel(),