[MM-66420] Add missing tests for playbooks (#9258)

* [MM-66420] Add missing tests for playbooks

* i18n-extract

* Update message keys to more accurate ones

* Update status update post to use a more standard error handling and fix linter bugs

* Add post-merge tests and address feedback

* Fix failing test

* Address feedback
This commit is contained in:
Daniel Espino García 2025-11-06 12:01:25 +01:00 committed by GitHub
parent db154996f4
commit 47e4bbca78
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
58 changed files with 6746 additions and 731 deletions

View file

@ -95,6 +95,7 @@ const Button = ({
const loadingComponent = (
<Loading
color={txtStyleToUse.color}
testID={`${testID}-loader`}
/>
);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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', () => {

View file

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

View file

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

View file

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

View file

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

View file

@ -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(<PlaybookCard {...props}/>);
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(<PlaybookCard {...props}/>);
@ -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(<PlaybookCard {...props}/>);
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';

View file

@ -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<typeof RunList> {
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(<RunList {...props}/>);
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(<RunList {...props}/>);
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(<RunList {...props}/>);
// 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(<RunList {...props}/>);
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(<RunList {...props}/>);
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(<RunList {...props}/>);
// 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(<RunList {...props}/>);
// 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(<RunList {...props}/>);
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(<RunList {...props}/>);
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(<RunList {...props}/>);
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(<RunList {...props}/>);
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(<RunList {...props}/>);
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(<RunList {...props}/>);
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(<RunList {...props}/>, {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(<RunList {...props}/>);
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(<RunList {...props}/>);
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(<RunList {...props}/>);
// 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(<RunList {...props}/>);
// 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(<RunList {...props}/>);
act(() => {
fireEvent.press(getByText('Start a new run'));
});
expect(goToSelectPlaybook).toHaveBeenCalledTimes(1);
expect(goToSelectPlaybook).toHaveBeenCalledWith(expect.anything(), expect.anything(), 'channel-id-1');
});
});

View file

@ -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<PlaybookRunModel | PlaybookRun>;
finishedRuns: Array<PlaybookRunModel | PlaybookRun>;
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 <View style={itemSeparatorStyle.itemSeparator}/>;
};
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<TabDefinition<RunListTabsNames>> = [
{
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<RunListTabsNames>(initialTab, tabs);
const data = activeTab === 'in-progress' ? inProgressRuns : finishedRuns;
const isEmpty = data.length === 0;
const onShowMorePress = useCallback(() => {
fetchMoreRuns(activeTab);
}, [fetchMoreRuns, activeTab]);
const footerComponent = useMemo(() => (
<ShowMoreButton
fetching={fetching}
onPress={onShowMorePress}
visible={showMoreButton(activeTab)}
/>
), [fetching, onShowMorePress, showMoreButton, activeTab]);
const startANewRunButtonContainerStyle = useMemo(() => {
return [styles.startANewRunButtonContainer, {paddingBottom: insets.bottom}];
}, [insets.bottom, styles]);
const renderItem: ListRenderItem<PlaybookRunModel> = useCallback(({item}) => {
return (
<PlaybookCard
run={item}
location={componentId}
/>
);
}, [componentId]);
const startANewRun = useCallback(() => {
goToSelectPlaybook(intl, theme, channelId);
}, [intl, theme, channelId]);
let content;
if (loading) {
content = <Loading testID='loading'/>;
} else if (isEmpty) {
content = (<EmptyState tab={activeTab}/>);
} else {
content = (
<>
<FlashList
data={data}
renderItem={renderItem}
contentContainerStyle={styles.container}
ItemSeparatorComponent={ItemSeparator}
estimatedItemSize={CARD_HEIGHT}
ListFooterComponent={footerComponent}
testID='runs.list'
/>
</>
);
}
return (
<>
<View style={styles.tabContainer}>
<Tabs {...tabsProps}/>
</View>
{showCachedWarning && (
<View style={styles.warningContainer}>
<SectionNotice
title={intl.formatMessage(messages.cachedWarningTitle)}
location={componentId}
text={intl.formatMessage(messages.cachedWarningMessage)}
type='warning'
/>
</View>
)}
{content}
<View style={startANewRunButtonContainerStyle}>
<Button
emphasis='tertiary'
onPress={startANewRun}
text={intl.formatMessage({id: 'playbooks.runs.start_a_new_run', defaultMessage: 'Start a new run'})}
size='lg'
theme={theme}
iconName='play-outline'
/>
</View>
</>
);
};
export default RunList;

View file

@ -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<typeof ShowMoreButton> {
return {
fetching: false,
onPress: jest.fn(),
visible: true,
};
}
beforeEach(() => {
jest.clearAllMocks();
});
it('renders correctly when visible', () => {
const props = getBaseProps();
const {getByText} = renderWithIntl(<ShowMoreButton {...props}/>);
const button = getByText('Show More');
expect(button).toBeTruthy();
});
it('calls onPress when button is pressed', async () => {
const props = getBaseProps();
const {getByText} = renderWithIntl(<ShowMoreButton {...props}/>);
await act(async () => {
fireEvent.press(getByText('Show More'));
});
expect(props.onPress).toHaveBeenCalled();
});
it('prevents double tap', async () => {
const props = getBaseProps();
const {getByText} = renderWithIntl(<ShowMoreButton {...props}/>);
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(<ShowMoreButton {...props}/>);
expect(queryByText('Show More')).toBeNull();
});
it('shows loader when fetching is true', () => {
const props = getBaseProps();
props.fetching = true;
const {getByTestId} = renderWithIntl(<ShowMoreButton {...props}/>);
expect(getByTestId('show-more-button-loader')).toBeTruthy();
});
});

View file

@ -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 (
<View style={styles.container}>
<Button
text={intl.formatMessage({id: 'playbooks.runs.show_more', defaultMessage: 'Show More'})}
emphasis='tertiary'
onPress={doubleTapPreventedOnPress}
theme={theme}
showLoader={fetching}
testID='show-more-button'
/>
</View>
);
};
export default ShowMoreButton;

View file

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

View file

@ -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<typeof StatusUpdatePost> {
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(<StatusUpdatePost {...props}/>, {database});
expect(getByText(/posted an update for/)).toBeTruthy();
expect(getByTestId('participants')).toBeTruthy();
});
it('should fetch users by ids on mount', () => {
const props = getBaseProps();
renderWithEverything(<StatusUpdatePost {...props}/>, {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(<StatusUpdatePost {...props}/>, {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(<StatusUpdatePost {...props}/>, {database});
expect(getByText('This is a status update message')).toBeTruthy();
});
it('should render tasks information with correct icon', () => {
const props = getBaseProps();
const {getByTestId} = renderWithEverything(<StatusUpdatePost {...props}/>, {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(<StatusUpdatePost {...props}/>, {database});
expect(getByText('7 of 10 tasks checked')).toBeTruthy();
});
it('should render participants component with correct participant ids', () => {
const props = getBaseProps();
const {getByTestId} = renderWithEverything(<StatusUpdatePost {...props}/>, {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(<StatusUpdatePost {...props}/>, {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(<StatusUpdatePost {...props}/>, {database});
expect(getByText('Playbooks status update post with invalid properties')).toBeTruthy();
});
});

View file

@ -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 (
<View style={style.messageContainer}>
<Markdown
baseTextStyle={style.message}
channelId={post.channelId}
postId={post.id}
value={intl.formatMessage({id: 'playbooks.status_update_post.invalid_status_update_props', defaultMessage: 'Playbooks status update post with invalid properties'})}
theme={theme}
location={location}
/>
</View>
);
}
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 (
<View style={style.messageContainer}>
<Markdown
@ -116,7 +128,6 @@ const StatusUpdatePost = ({location, post, theme}: Props) => {
channelId={post.channelId}
postId={post.id}
value={updatePosted}
mentionKeys={mentionKeys}
theme={theme}
location={location}
/>

View file

@ -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<typeof EnhancedParticipants> {
return {
participantIds: ['user-1', 'user-2'],
location: 'Channel',
baseTextStyle: {},
};
}
it('should render correctly without data in the database', () => {
const props = getBaseProps();
const {getByTestId} = renderWithEverything(
<EnhancedParticipants {...props}/>,
{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(
<EnhancedParticipants {...props}/>,
{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(
<EnhancedParticipants {...props}/>,
{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(<EnhancedParticipants {...props}/>);
const component2 = getByTestId('participants');
expect(component2.props.users).toHaveLength(1);
expect(component2.props.users[0].id).toBe(user1.id);
});
});

View file

@ -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<typeof Participants> {
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(<Participants {...props}/>, {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(<Participants {...props}/>, {database, serverUrl});
expect(getByText('1 participant')).toBeVisible();
});
it('opens bottom sheet when pressed', async () => {
const props = getBaseProps();
const {root} = renderWithEverything(<Participants {...props}/>, {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(<Participants {...props}/>, {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(<Content/>, {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(<Participants {...props}/>, {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(<Content/>, {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(<Participants {...props}/>, {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(<Content/>, {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(<Participants {...props}/>, {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(<Participants {...props}/>, {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(<Participants {...props}/>, {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(<Participants {...props}/>, {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(<Participants {...props}/>, {database, serverUrl});
await act(async () => {
fireEvent.press(root);
});
await waitFor(() => {
fireEvent.press(root);
});
expect(bottomSheet).toHaveBeenCalledTimes(1);
});
});

View file

@ -7,7 +7,7 @@ import {goToScreen} from '@screens/navigation';
import TestHelper from '@test/test_helper';
import {changeOpacity} from '@utils/theme';
import {goToPlaybookRuns, goToPlaybookRun, goToParticipantPlaybooks, goToPlaybookRunWithChannelSwitch, goToEditCommand, goToSelectUser, goToSelectDate} 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),
},
},
},
);
});
});
});

View file

@ -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<ReturnType<typeof fetchPlaybookRunsPageForParticipant>>;
describe('ParticipantPlaybooks', () => {
@ -71,60 +85,71 @@ describe('ParticipantPlaybooks', () => {
});
jest.mocked(fetchPlaybookRunsPageForParticipant).mockReturnValue(pendingPromise);
const {getByTestId, queryByTestId} = renderWithEverything(<ParticipantPlaybooks {...props}/>, {database, serverUrl});
const {getByTestId} = renderWithEverything(<ParticipantPlaybooks {...props}/>, {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(<ParticipantPlaybooks {...props}/>, {database, serverUrl});
const {getByTestId} = renderWithEverything(<ParticipantPlaybooks {...props}/>, {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(<ParticipantPlaybooks {...props}/>, {database, serverUrl});
const {getByTestId} = renderWithEverything(<ParticipantPlaybooks {...props}/>, {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(<ParticipantPlaybooks {...props}/>, {database, serverUrl});
const {getByTestId} = renderWithEverything(<ParticipantPlaybooks {...props}/>, {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(<ParticipantPlaybooks {...props}/>, {database, serverUrl});
const {getByTestId} = renderWithEverything(<ParticipantPlaybooks {...props}/>, {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(<ParticipantPlaybooks {...props}/>, {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(<ParticipantPlaybooks {...props}/>, {database, serverUrl});
const {findByTestId} = renderWithEverything(<ParticipantPlaybooks {...props}/>, {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');
const showMoreButton = runList.props.showMoreButton;
expect(showMoreButton('finished')).toBe(true);
expect(showMoreButton('in-progress')).toBe(true);
});
expect(fetchPlaybookRunsPageForParticipant).toHaveBeenCalledWith('server-url', 'test-user-id', 1);
expect(fetchPlaybookRunsPageForParticipant).toHaveBeenCalledTimes(2);
});
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(<ParticipantPlaybooks {...props}/>, {database, serverUrl});
const {findByTestId} = renderWithEverything(<ParticipantPlaybooks {...props}/>, {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(<ParticipantPlaybooks {...props}/>, {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(<ParticipantPlaybooks {...props}/>, {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(<ParticipantPlaybooks {...props}/>, {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(<ParticipantPlaybooks {...props}/>, {database, serverUrl});
const {getByTestId} = renderWithEverything(<ParticipantPlaybooks {...props}/>, {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(<ParticipantPlaybooks {...props}/>, {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(<ParticipantPlaybooks {...props}/>, {database, serverUrl});
const {getByTestId} = renderWithEverything(<ParticipantPlaybooks {...props}/>, {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(<ParticipantPlaybooks {...props}/>, {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(<ParticipantPlaybooks {...props}/>, {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);
});
});
});

View file

@ -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<TabDefinition<TabsNames>> = [
{
id: 'in-progress',
name: messages.tabInProgress,
},
{
id: 'finished',
name: messages.tabFinished,
},
];
const itemSeparatorStyle = {
height: 12,
};
const ItemSeparator = () => {
return <View style={itemSeparatorStyle}/>;
};
const ParticipantPlaybooks = ({
currentUserId,
componentId,
cachedPlaybookRuns,
}: Props) => {
const intl = useIntl();
const serverUrl = useServerUrl();
const theme = useTheme();
const styles = getStyleFromTheme(theme);
const [participantRuns, setParticipantRuns] = useState<Array<PlaybookRun | PlaybookRunModel>>([]);
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<PlaybookRun | PlaybookRunModel> = [];
const finished: Array<PlaybookRun | PlaybookRunModel> = [];
@ -184,59 +110,18 @@ const ParticipantPlaybooks = ({
return [inProgress, finished] as const;
}, [participantRuns]);
const [activeTab, tabsProps] = useTabs<TabsNames>('in-progress', tabs);
const data = activeTab === 'in-progress' ? inProgressRuns : finishedRuns;
const isEmpty = data.length === 0;
const renderItem: ListRenderItem<PlaybookRun> = useCallback(({item}) => {
return (
<PlaybookCard
run={item}
location={PlaybookScreens.PARTICIPANT_PLAYBOOKS}
<RunList
componentId={componentId}
inProgressRuns={inProgressRuns}
finishedRuns={finishedRuns}
fetchMoreRuns={loadMore}
showMoreButton={showMoreButton}
fetching={loadingMore}
loading={loading}
showCachedWarning={showCachedWarning}
/>
);
}, []);
let content;
if (loading) {
content = <Loading testID='loading'/>;
} else if (hasError || isEmpty) {
content = (<EmptyState tab={activeTab}/>);
} else {
content = (
<FlashList
data={data}
renderItem={renderItem}
contentContainerStyle={styles.container}
ItemSeparatorComponent={ItemSeparator}
estimatedItemSize={CARD_HEIGHT}
onEndReached={loadMore}
onEndReachedThreshold={0.1}
ListFooterComponent={loadingMore ? <Loading testID='loading.more'/> : undefined}
testID={'runs.list'}
/>
);
}
return (
<>
<View style={styles.tabContainer}>
<Tabs {...tabsProps}/>
</View>
{showCachedWarning && (
<View style={styles.warningContainer}>
<SectionNotice
title={intl.formatMessage(messages.cachedWarningTitle)}
location={PlaybookScreens.PARTICIPANT_PLAYBOOKS}
text={intl.formatMessage(messages.cachedWarningMessage)}
type='warning'
/>
</View>
)}
{content}
</>
);
};
export default ParticipantPlaybooks;

View file

@ -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(<PlaybookRun {...props}/>, {database});
expect(useAndroidHardwareBackHandler).toHaveBeenCalledWith(props.componentId, expect.any(Function));
const closeHandler = jest.mocked(useAndroidHardwareBackHandler).mock.calls[0][1];
closeHandler();
expect(popTopScreen).toHaveBeenCalled();
});
});

View file

@ -179,7 +179,6 @@ export default function PlaybookRun({
useAndroidHardwareBackHandler(componentId, () => {
popTopScreen();
return true;
});
const isParticipant = participants.some((p) => p.id === currentUserId) || owner?.id === currentUserId;

View file

@ -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(
<StatusUpdateIndicator
isFinished={false}
timestamp={futureTimestamp}
isParticipant={true}
playbookRunId='run-id'
/>,
);
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(
<StatusUpdateIndicator
isFinished={true}
timestamp={futureTimestamp}
isParticipant={true}
playbookRunId='run-id'
/>,
);
const button = getByText('Post update');
act(() => {
fireEvent.press(button);
});
expect(goToPostUpdate).not.toHaveBeenCalled();
});
});

View file

@ -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 (

View file

@ -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(
<PlaybookRuns
allRuns={[]}
@ -37,57 +50,29 @@ describe('PlaybookRuns', () => {
/>,
);
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(
<PlaybookRuns
allRuns={[inProgressRun, finishedRun]}
componentId={'PlaybookRuns'}
channelId={'channel-id-1'}
/>,
);
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(
<PlaybookRuns
allRuns={[finishedRun]}
componentId={'PlaybookRuns'}
channelId={'channel-id-1'}
/>,
);
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(
<PlaybookRuns
allRuns={[inProgressRun, finishedRun]}
componentId={'PlaybookRuns'}
@ -95,12 +80,189 @@ describe('PlaybookRuns', () => {
/>,
);
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(
<PlaybookRuns
allRuns={[inProgressRun]}
componentId={'PlaybookRuns'}
channelId={'channel-id-1'}
/>,
);
const runList = getByTestId('run-list');
expect(runList.props.showMoreButton('finished')).toBe(true);
jest.mocked(fetchFinishedRunsForChannel).mockResolvedValue({runs: [], has_more: false});
act(() => {
runList.props.fetchMoreRuns('finished');
});
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(
<PlaybookRuns
allRuns={[inProgressRun]}
componentId={'PlaybookRuns'}
channelId={'channel-id-1'}
/>,
);
const runList = getByTestId('run-list');
let fetchMoreRuns = runList.props.fetchMoreRuns;
act(() => {
fireEvent.press(getByText('Finished'));
fetchMoreRuns('finished');
});
expect(getByText('Show More')).toBeTruthy();
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(
<PlaybookRuns
allRuns={[inProgressRun]}
componentId={'PlaybookRuns'}
channelId={'channel-id-1'}
/>,
);
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(
<PlaybookRuns
allRuns={[inProgressRun]}
componentId={'PlaybookRuns'}
channelId={'channel-id-1'}
/>,
);
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(
<PlaybookRuns
allRuns={[inProgressRun]}
componentId={'PlaybookRuns'}
channelId={'channel-id-1'}
/>,
);
const runList = getByTestId('run-list');
act(() => {
runList.props.fetchMoreRuns('in-progress');
});
expect(fetchFinishedRunsForChannel).not.toHaveBeenCalled();
});
it('handles Android back button', async () => {
renderWithIntl(
<PlaybookRuns
allRuns={[inProgressRun]}
componentId={'PlaybookRuns'}
channelId={'channel-id-1'}
/>,
);
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(
<PlaybookRuns
allRuns={[inProgressRun]}
componentId={'PlaybookRuns'}
channelId={'channel-id-1'}
/>,
);
const runList = getByTestId('run-list');
expect(runList).toHaveProp('channelId', 'channel-id-1');
});
});

View file

@ -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 <View style={itemSeparatorStyle.itemSeparator}/>;
};
const tabs: Array<TabDefinition<TabsNames>> = [
{
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<PlaybookRun[]>([]);
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<PlaybookRun[]>([]);
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<TabsNames>(initialTab, tabs);
const showMoreButton = useCallback((tab: RunListTabsNames) => {
return tab === 'finished' && hasMore;
}, [hasMore]);
let data: Array<PlaybookRunModel | PlaybookRun> = inProgressRuns;
if (activeTab === 'finished') {
if (fetchedFinishedRuns.length) {
data = fetchedFinishedRuns;
} else {
data = finishedRuns;
const fetchMoreFinishedRuns = useCallback(async (tab: RunListTabsNames) => {
if (fetching || tab !== 'finished') {
return;
}
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]);
const isEmpty = data.length === 0;
const footerComponent = useMemo(() => (
<ShowMoreButton
return (
<RunList
componentId={componentId}
inProgressRuns={inProgressRuns}
finishedRuns={remoteFinishedRuns.length ? remoteFinishedRuns : localFinishedRuns}
fetchMoreRuns={fetchMoreFinishedRuns}
showMoreButton={showMoreButton}
fetching={fetching}
channelId={channelId}
addMoreRuns={pushNewFinishedRuns}
visible={activeTab === 'finished'}
/>
), [channelId, pushNewFinishedRuns, activeTab]);
const renderItem: ListRenderItem<PlaybookRunModel> = useCallback(({item}) => {
return (
<PlaybookCard
run={item}
location={Screens.PLAYBOOKS_RUNS}
/>
);
}, []);
const startANewRun = useCallback(() => {
goToSelectPlaybook(intl, theme, channelId);
}, [intl, theme, channelId]);
let content = (<EmptyState tab={activeTab}/>);
if (!isEmpty) {
content = (
<>
<FlashList
data={data}
renderItem={renderItem}
contentContainerStyle={styles.container}
ItemSeparatorComponent={ItemSeparator}
estimatedItemSize={CARD_HEIGHT}
ListFooterComponent={footerComponent}
/>
<View style={styles.startANewRunButtonContainer}>
<Button
emphasis='tertiary'
onPress={startANewRun}
text={intl.formatMessage({id: 'playbooks.runs.start_a_new_run', defaultMessage: 'Start a new run'})}
size='lg'
theme={theme}
iconName='play-outline'
/>
</View>
</>
);
}
return (
<>
<View style={styles.tabContainer}>
<Tabs {...tabsProps}/>
</View>
{content}
</>
);
};

View file

@ -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<typeof ShowMoreButton> {
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(<ShowMoreButton {...props}/>);
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(<ShowMoreButton {...props}/>);
// 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(<ShowMoreButton {...props}/>);
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(<ShowMoreButton {...props}/>);
// 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(<ShowMoreButton {...props}/>);
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(<ShowMoreButton {...props}/>);
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(<ShowMoreButton {...props}/>);
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(<ShowMoreButton {...props}/>);
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(<ShowMoreButton {...props}/>);
expect(queryByText('Show More')).toBeNull();
});
});

View file

@ -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 (
<View style={styles.container}>
<Button
text={intl.formatMessage({id: 'playbooks.runs.show_more', defaultMessage: 'Show More'})}
emphasis='tertiary'
onPress={fetchFinishedRuns}
theme={theme}
showLoader={fetching}
/>
</View>
);
};
export default ShowMoreButton;

View file

@ -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<typeof PostUpdate> {
return {
playbookRunId,
componentId: 'PlaybookPostUpdate',
};
}
it('should render correctly with no data', () => {
const props = getBaseProps();
const {getByTestId} = renderWithEverything(<PostUpdate {...props}/>, {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(<PostUpdate {...props}/>, {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(<PostUpdate {...props}/>, {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(<PostUpdate {...props}/>, {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
});
});
});
});

View file

@ -18,7 +18,6 @@ import type {WithDatabaseArgs} from '@typings/database/database';
type OwnProps = {
playbookRunId: string;
playbookRun?: PlaybookRun;
} & WithDatabaseArgs;
const getIds = (checklists: PlaybookChecklistModel[]) => {

View file

@ -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<T, U extends any[], V>(mock: jest.Mock<T, U, V>): U {
const allCalls = mock.mock.calls;
return allCalls[allCalls.length - 1];
}
describe('PostUpdate', () => {
function getBaseProps(): ComponentProps<typeof PostUpdate> {
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(<PostUpdate {...props}/>);
expect(getByTestId('loader')).toBeTruthy();
});
it('should render correctly after loading', async () => {
const props = getBaseProps();
const {getByText, getByTestId} = renderWithIntlAndTheme(<PostUpdate {...props}/>);
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(<PostUpdate {...props}/>);
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(<PostUpdate {...props}/>);
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(<PostUpdate {...props}/>);
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(<PostUpdate {...props}/>);
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(<PostUpdate {...props}/>);
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(<PostUpdate {...props}/>);
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(<PostUpdate {...props}/>);
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(<PostUpdate {...props}/>);
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(<PostUpdate {...props}/>);
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(<PostUpdate {...props}/>);
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(<PostUpdate {...props}/>);
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(<PostUpdate {...props}/>);
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(<PostUpdate {...props}/>);
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(<PostUpdate {...props}/>);
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(<PostUpdate {...props}/>);
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(<PostUpdate {...props}/>);
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(<PostUpdate {...props}/>);
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(<PostUpdate {...props}/>);
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(<PostUpdate {...props}/>);
await waitFor(() => {
expect(logDebug).toHaveBeenCalledWith('error on fetchPlaybookRun', 'Run fetch error');
});
});
it('should handle Android back button press', () => {
const props = getBaseProps();
renderWithIntlAndTheme(<PostUpdate {...props}/>);
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(<PostUpdate {...props}/>);
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(<PostUpdate {...props}/>);
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(<PostUpdate {...props}/>);
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(<PostUpdate {...props}/>);
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();
});
});
});

View file

@ -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 <Loading/>;
return <Loading testID='loader'/>;
}
let introMessage;
if (broadcastChannelCount + followersCount === 0) {

View file

@ -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<typeof SelectPlaybook> {
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(<SelectPlaybook {...props}/>, {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<string>());
});
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(<SelectPlaybook {...props}/>, {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<string>());
});
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(<SelectPlaybook {...props}/>, {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(<SelectPlaybook {...props}/>, {database});
await waitFor(() => {
const selectPlaybook = getByTestId('select-playbook');
expect(selectPlaybook.props.playbooksUsedInChannel).toEqual(new Set<string>());
});
});
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(<SelectPlaybook {...props}/>, {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(<SelectPlaybook {...props}/>, {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(<SelectPlaybook {...props}/>, {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);
});
});
});

View file

@ -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<typeof PlaybookRow> {
return {
playbook: TestHelper.fakePlaybook({
title: 'Test Playbook',
}),
onPress: jest.fn(),
};
}
it('renders playbook title correctly', () => {
const props = getBaseProps();
const {getByText} = renderWithIntlAndTheme(
<PlaybookRow {...props}/>,
);
expect(getByText('Test Playbook')).toBeTruthy();
});
it('renders public playbook icon correctly', () => {
const props = getBaseProps();
props.playbook.public = true;
const {getByTestId} = renderWithIntlAndTheme(
<PlaybookRow {...props}/>,
);
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(
<PlaybookRow {...props}/>,
);
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(
<PlaybookRow {...props}/>,
);
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(
<PlaybookRow {...props}/>,
);
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(
<PlaybookRow {...props}/>,
);
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(
<PlaybookRow {...props}/>,
);
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(
<PlaybookRow {...props}/>,
);
expect(getByText(/5 runs in progress/)).toBeTruthy();
});
it('calls onPress when pressed', async () => {
const props = getBaseProps();
const {getByText} = renderWithIntlAndTheme(
<PlaybookRow {...props}/>,
);
await act(async () => {
fireEvent.press(getByText('Test Playbook'));
});
expect(props.onPress).toHaveBeenCalledTimes(1);
expect(props.onPress).toHaveBeenCalledWith(props.playbook);
});
});

View file

@ -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) => {
<TouchableOpacity
onPress={handlePress}
style={styles.container}
testID={testID}
activeOpacity={0.7}
>
<CompassIcon

View file

@ -0,0 +1,628 @@
// 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 {switchToChannelById} from '@actions/remote/channel';
import SearchBar from '@components/search';
import {Screens} from '@constants';
import {useServerUrl} from '@context/server';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import {fetchPlaybooks} from '@playbooks/actions/remote/playbooks';
import {fetchPlaybookRunsForChannel} from '@playbooks/actions/remote/runs';
import {
popTo,
popTopScreen,
} from '@screens/navigation';
import {renderWithIntlAndTheme} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
import {goToPlaybookRun, goToStartARun} from '../navigation';
import PlaybookRow from './playbook_row';
import SelectPlaybook from './select_playbook';
jest.mock('@actions/remote/channel', () => ({
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<typeof SelectPlaybook> {
return {
currentTeamId: 'team-id-1',
currentUserId: 'user-id-1',
componentId: 'PlaybooksSelectPlaybook',
playbooksUsedInChannel: new Set<string>(),
};
}
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(<SelectPlaybook {...props}/>);
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(<SelectPlaybook {...props}/>);
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(<SelectPlaybook {...props}/>);
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(<SelectPlaybook {...props}/>);
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(<SelectPlaybook {...props}/>);
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(<SelectPlaybook {...props}/>);
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(<SelectPlaybook {...props}/>);
// 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(<SelectPlaybook {...props}/>);
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(<SelectPlaybook {...props}/>);
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(<SelectPlaybook {...props}/>);
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(<SelectPlaybook {...props}/>);
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(<SelectPlaybook {...props}/>);
// 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(<SelectPlaybook {...props}/>);
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(<SelectPlaybook {...props}/>);
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(<SelectPlaybook {...props}/>);
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(<SelectPlaybook {...props}/>);
await waitFor(() => {
expect(getByText('No Results')).toBeVisible();
});
});
});

View file

@ -117,7 +117,7 @@ function SelectPlaybook({
const [searchResults, setSearchResults] = useState<Playbook[]>([]);
const page = useRef<number>(-1);
const next = useRef<boolean>(true);
const hasMore = useRef<boolean>(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}
>
<View
testID='integration_selector.screen'
style={style.searchBar}
>
<SearchBar
@ -331,8 +333,8 @@ function SelectPlaybook({
data={shownData}
renderItem={renderItem}
ListEmptyComponent={renderNoResults}
onEndReached={loadMore}
ListFooterComponent={renderLoading}
testID='selector.flat_list'
/>
)}
{!term && (
@ -343,6 +345,7 @@ function SelectPlaybook({
ListEmptyComponent={renderNoResults}
onEndReached={loadMore}
ListFooterComponent={renderLoading}
testID='selector.section_list'
/>
)}
</SafeAreaView>

View file

@ -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<typeof StartARun> {
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(<StartARun {...props}/>, {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(<StartARun {...props}/>, {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(<StartARun {...props}/>, {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');
});
});
});

View file

@ -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<T, U extends unknown[], V>(mock: jest.Mock<T, U, V>): U {
const allCalls = mock.mock.calls;
return allCalls[allCalls.length - 1];
}
function getLastCallForButton<T, U extends unknown[], V>(mock: jest.Mock<T, U, V>, 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<typeof StartARun> {
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(<StartARun {...props}/>);
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(<StartARun {...props}/>);
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(<StartARun {...props}/>);
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(<StartARun {...props}/>);
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(<StartARun {...props}/>);
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(<StartARun {...props}/>);
// 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(<StartARun {...props}/>);
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(<StartARun {...props}/>);
// 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(<StartARun {...props}/>);
// 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(<StartARun {...props}/>);
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(<StartARun {...props}/>);
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(<StartARun {...props}/>);
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(<StartARun {...props}/>);
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(<StartARun {...props}/>);
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(<StartARun {...props}/>);
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(<StartARun {...props}/>);
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(<StartARun {...props}/>);
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(<StartARun {...props}/>);
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(<StartARun {...props}/>);
// 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(<StartARun {...props}/>);
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(<StartARun {...props}/>);
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(<StartARun {...props}/>);
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(<StartARun {...props}/>);
// 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(<StartARun {...props}/>);
// 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(<StartARun {...props}/>);
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(<StartARun {...props}/>);
// 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(<StartARun {...props}/>);
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(<StartARun {...props}/>);
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(<StartARun {...props}/>);
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');
});
});

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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, youll see it here.",
"playbooks.runs.finished.title": "No finished runs",
"playbooks.runs.in_progress.description": "When a run starts in this channel, youll 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})",

View file

@ -1180,6 +1180,33 @@ class TestHelperSingleton {
return playbookRuns;
};
fakePlaybook = (overwrite: Partial<Playbook> = {}): 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> = {}): PlaybookRun => {
const run = this.createPlaybookRuns(1, 0, 0);
@ -1207,6 +1234,18 @@ class TestHelperSingleton {
};
};
fakePlaybookRunMetadata = (overwrite: Partial<PlaybookRunMetadata> = {}): 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> = {}): PlaybookRunModel => {
return {
...this.fakeModel(),