[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:
parent
db154996f4
commit
47e4bbca78
58 changed files with 6746 additions and 731 deletions
|
|
@ -95,6 +95,7 @@ const Button = ({
|
|||
const loadingComponent = (
|
||||
<Loading
|
||||
color={txtStyleToUse.color}
|
||||
testID={`${testID}-loader`}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
112
app/products/playbooks/actions/remote/playbooks.test.ts
Normal file
112
app/products/playbooks/actions/remote/playbooks.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
151
app/products/playbooks/actions/websocket/events.test.ts
Normal file
151
app/products/playbooks/actions/websocket/events.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
9
app/products/playbooks/components/run_list/index.ts
Normal file
9
app/products/playbooks/components/run_list/index.ts
Normal 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};
|
||||
|
|
@ -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';
|
||||
357
app/products/playbooks/components/run_list/run_list.test.tsx
Normal file
357
app/products/playbooks/components/run_list/run_list.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
200
app/products/playbooks/components/run_list/run_list.tsx
Normal file
200
app/products/playbooks/components/run_list/run_list.tsx
Normal 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;
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
4
app/products/playbooks/components/run_list/types.tsx
Normal file
4
app/products/playbooks/components/run_list/types.tsx
Normal 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';
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -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),
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
||||
expect(queryByText(/Showing cached data only/)).not.toBeVisible();
|
||||
await waitFor(() => {
|
||||
const runList = getByTestId('run_list');
|
||||
expect(runList).toHaveProp('loading', false);
|
||||
expect(runList).toHaveProp('showCachedWarning', false);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -179,7 +179,6 @@ export default function PlaybookRun({
|
|||
|
||||
useAndroidHardwareBackHandler(componentId, () => {
|
||||
popTopScreen();
|
||||
return true;
|
||||
});
|
||||
|
||||
const isParticipant = participants.some((p) => p.id === currentUserId) || owner?.id === currentUserId;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
263
app/products/playbooks/screens/post_update/index.test.tsx
Normal file
263
app/products/playbooks/screens/post_update/index.test.tsx
Normal 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
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -18,7 +18,6 @@ import type {WithDatabaseArgs} from '@typings/database/database';
|
|||
|
||||
type OwnProps = {
|
||||
playbookRunId: string;
|
||||
playbookRun?: PlaybookRun;
|
||||
} & WithDatabaseArgs;
|
||||
|
||||
const getIds = (checklists: PlaybookChecklistModel[]) => {
|
||||
|
|
|
|||
748
app/products/playbooks/screens/post_update/post_update.test.tsx
Normal file
748
app/products/playbooks/screens/post_update/post_update.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
309
app/products/playbooks/screens/select_playbook/index.test.tsx
Normal file
309
app/products/playbooks/screens/select_playbook/index.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
141
app/products/playbooks/screens/start_a_run/index.test.tsx
Normal file
141
app/products/playbooks/screens/start_a_run/index.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
776
app/products/playbooks/screens/start_a_run/start_a_run.test.tsx
Normal file
776
app/products/playbooks/screens/start_a_run/start_a_run.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
11
app/products/playbooks/types/post.ts
Normal file
11
app/products/playbooks/types/post.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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'}),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
310
app/products/playbooks/utils/types.test.ts
Normal file
310
app/products/playbooks/utils/types.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
40
app/products/playbooks/utils/types.ts
Normal file
40
app/products/playbooks/utils/types.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -988,8 +988,6 @@
|
|||
"playbook_run.out_of_date_header.message": "Unable to connect to server. Content may be out of date. Last updated {lastUpdated}.",
|
||||
"playbook.last_updated": "Last update {date}",
|
||||
"playbook.participants": "Run Participants",
|
||||
"playbook.runs.finished": "Finished",
|
||||
"playbook.runs.in-progress": "In Progress",
|
||||
"playbooks.checklist_item.assignee": "Assignee",
|
||||
"playbooks.checklist_item.check": "Check",
|
||||
"playbooks.checklist_item.checked": "Checked",
|
||||
|
|
@ -1021,10 +1019,6 @@
|
|||
"playbooks.only_runs_available.description": "Only Playbook Runs are available on mobile. To access the Playbook, please use the desktop or web app.",
|
||||
"playbooks.only_runs_available.ok": "OK",
|
||||
"playbooks.only_runs_available.title": "Playbooks not available",
|
||||
"playbooks.participant_playbooks.cached_warning_message": "Showing cached data only. Some playbook runs or updates may be missing from this list.",
|
||||
"playbooks.participant_playbooks.cached_warning_title": "Cannot reach the server",
|
||||
"playbooks.participant_playbooks.tab_finished": "Finished",
|
||||
"playbooks.participant_playbooks.tab_in_progress": "In Progress",
|
||||
"playbooks.participant_playbooks.title": "Playbook runs",
|
||||
"playbooks.playbook_run.error.description": "Please check your network connection or try again later.",
|
||||
"playbooks.playbook_run.error.title": "Unable to fetch run details",
|
||||
|
|
@ -1072,6 +1066,10 @@
|
|||
"playbooks.row.never_used": "Never used",
|
||||
"playbooks.row.no_runs": "No runs in progress",
|
||||
"playbooks.row.runs": "{count} {count, plural, one {run} other {runs}} in progress",
|
||||
"playbooks.run_list.cached_warning_message": "Showing cached data only. Some playbook runs or updates may be missing from this list.",
|
||||
"playbooks.run_list.cached_warning_title": "Cannot reach the server",
|
||||
"playbooks.run_list.tab_finished": "Finished",
|
||||
"playbooks.run_list.tab_in_progress": "In Progress",
|
||||
"playbooks.runs.finished.description": "When a run in this channel finishes, you’ll see it here.",
|
||||
"playbooks.runs.finished.title": "No finished runs",
|
||||
"playbooks.runs.in_progress.description": "When a run starts in this channel, you’ll see it here.",
|
||||
|
|
@ -1097,6 +1095,7 @@
|
|||
"playbooks.start_run.run_name_error": "Please add a name for this run",
|
||||
"playbooks.start_run.run_name_label": "Run name",
|
||||
"playbooks.start_run.run_name_placeholder": "Add a name for your run",
|
||||
"playbooks.status_update_post.invalid_status_update_props": "Playbooks status update post with invalid properties",
|
||||
"playbooks.status_update_post.num_tasks": "**{numTasksChecked, number}** of **{numTasks, number}** {numTasks, plural, =1 {task} other {tasks}} checked",
|
||||
"playbooks.status_update_post.participants": "{numParticipants, number} {numParticipants, plural, =1 {participant} other {participants}}",
|
||||
"playbooks.status_update_post.update": "@{authorUsername} posted an update for [{runName}]({link})",
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Reference in a new issue