Add Playbooks read-only support for mobile devices (#8978)

* Add the channel options to get into playbooks (#8750)

* Add the channel options to get into playbooks

* Use playbook run id instead of playbook id

* i18n

* Fix issues and move playbooks to the products folder

* Address some tests

* Fix test

* Address design issues

* Add requested comment

---------

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>

* Playbooks database (#8802)

* Add lastPlaybookFetchAt to channel table (#8916)

* Add lastPlaybookFetchAt to channel table

* Add missing commit

* Use my_channel table instead

* Fix test

* Address feedback

* First implementation of playbooks API (#8897)

* First implementation of playbooks API

* Add version check

* Address feedback

* Fix test

* Add last fetch at usage and other improvements

* Simplify test

* Add sort_order, update_at and previousReminder columns (#8927)

* Add sort_order, update_at and previousReminder columns

* Remove order from the schema

* Fix tests

* Add tests

* Add websockets for playbooks (#8947)

* Add websocket events for playbooks

* Fix typo

* Add playbook run list (#8761)

* Add the channel options to get into playbooks

* Use playbook run id instead of playbook id

* i18n

* Fix issues and move playbooks to the products folder

* Address some tests

* Fix test

* Add playbook run list

* Add missing texts

* Add back button support and item size to flash list

* Address design issues

* Add requested comment

* Standardize tag and use it in the card

* Fix merge

* Add API related functionality

---------

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>

* Add playbooks run details (#8872)

* Add the channel options to get into playbooks

* Use playbook run id instead of playbook id

* i18n

* Fix issues and move playbooks to the products folder

* Address some tests

* Fix test

* Add playbook run list

* Add missing texts

* Add back button support and item size to flash list

* Address design issues

* Add requested comment

* Standardize tag and use it in the card

* Add playbooks run details

* Fix merge

* Add API related functionality

* Add API related changes

* Order fixes

* Several fixes

* Add error state on playbook run

* i18n-extract

* Fix tests

* Fix test

* Several fixes

* Fixes and add missing UI elements

* i18n-extract

* Fix tests

* Remove files from bad merge

---------

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>

* Add missing tests for playbooks (#8976)

* Add the channel options to get into playbooks

* Use playbook run id instead of playbook id

* i18n

* Fix issues and move playbooks to the products folder

* Address some tests

* Fix test

* Add playbook run list

* Add missing texts

* Add back button support and item size to flash list

* Address design issues

* Add requested comment

* Standardize tag and use it in the card

* Add playbooks run details

* Fix merge

* Add API related functionality

* Add API related changes

* Order fixes

* Several fixes

* Add error state on playbook run

* i18n-extract

* Fix tests

* Fix test

* Several fixes

* Fixes and add missing UI elements

* i18n-extract

* Fix tests

* Remove files from bad merge

* Add tests

* Fix typo

* Add missing strings

* Fix tests and skip some

* Fix test

* Fix typo

---------

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>

* Address feedback

* Address feedback and fix tests

* Address comments and fix tests

* Address feedback

* Address plugin changes and fix bugs

---------

Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
This commit is contained in:
Daniel Espino García 2025-07-14 09:21:37 +02:00 committed by GitHub
parent dff9119519
commit bb7ff622af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
197 changed files with 13912 additions and 253 deletions

View file

@ -1,8 +1,6 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/* eslint-disable max-lines */
import {createIntl} from 'react-intl';
import {Alert, DeviceEventEmitter, Platform} from 'react-native';
@ -10,6 +8,7 @@ import {Events} from '@constants';
import {GLOBAL_IDENTIFIERS, SYSTEM_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
import NetworkManager from '@managers/network_manager';
import WebsocketManager from '@managers/websocket_manager';
import {logWarning} from '@utils/log';
import {
@ -56,6 +55,10 @@ const mockClient = {
logout: jest.fn(),
};
const mockWebsocketClient = {
close: jest.fn(),
};
let mockGetPushProxyVerificationState: jest.Mock;
jest.mock('@store/ephemeral_store', () => {
const original = jest.requireActual('@store/ephemeral_store');
@ -108,9 +111,11 @@ jest.mock('@utils/log', () => {
});
beforeAll(() => {
// eslint-disable-next-line
// @ts-ignore
NetworkManager.getClient = () => mockClient;
// @ts-ignore
WebsocketManager.getClient = () => mockWebsocketClient;
});
beforeEach(async () => {
@ -436,6 +441,7 @@ describe('logout', () => {
const shouldEmitBeforeAlert = shouldEmit && (!shouldShowAlert || options.logoutOnAlert);
const shouldEmitAfterAlert = shouldEmit && !shouldEmitBeforeAlert;
const expectedResult = !(shouldShowAlert && !options.logoutOnAlert);
const shouldCloseWebsocket = options.skipServerLogout || !clientReturnError || options.logoutOnAlert;
const clientCalls = shouldCallClient ? 1 : 0;
const alertButtons = options.logoutOnAlert ? 1 : 2;
@ -453,6 +459,13 @@ describe('logout', () => {
} else {
expect(mockEmit).not.toHaveBeenCalled();
}
if (shouldCloseWebsocket) {
expect(mockWebsocketClient.close).toHaveBeenCalledWith(true);
} else {
expect(mockWebsocketClient.close).not.toHaveBeenCalled();
}
if (shouldShowAlert) {
if (withIntl) {
expect(mockFormatMessage).toHaveBeenCalled();

View file

@ -10,6 +10,7 @@ import {SYSTEM_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
import PushNotifications from '@init/push_notifications';
import NetworkManager from '@managers/network_manager';
import WebsocketManager from '@managers/websocket_manager';
import {getDeviceToken} from '@queries/app/global';
import {getServerDisplayName} from '@queries/app/servers';
import {getCurrentUserId, getExpiredSession} from '@queries/servers/system';
@ -220,6 +221,7 @@ export const logout = async (
}
}
WebsocketManager.getClient(serverUrl)?.close(true);
if (!skipEvents) {
DeviceEventEmitter.emit(Events.SERVER_LOGOUT, {serverUrl, removeServer});
}

View file

@ -5,6 +5,7 @@ import * as bookmark from '@actions/local/channel_bookmark';
import * as scheduledPost from '@actions/websocket/scheduled_post';
import * as calls from '@calls/connection/websocket_event_handlers';
import {WebsocketEvents} from '@constants';
import {handlePlaybookEvents} from '@playbooks/actions/websocket/events';
import * as category from './category';
import * as channel from './channel';
@ -35,6 +36,7 @@ jest.mock('@calls/connection/websocket_event_handlers');
jest.mock('./group');
jest.mock('@actions/local/channel_bookmark');
jest.mock('@actions/websocket/scheduled_post');
jest.mock('@playbooks/actions/websocket/events');
describe('handleWebSocketEvent', () => {
const serverUrl = 'https://example.com';
@ -523,4 +525,14 @@ describe('handleWebSocketEvent', () => {
await handleWebSocketEvent(serverUrl, msg);
expect(scheduledPost.handleDeleteScheduledPost).toHaveBeenCalledWith(serverUrl, msg);
});
it('all messages should go through the playbooks handler', async () => {
msg.event = WebsocketEvents.POST_DELETED; // any handled event should be enough
await handleWebSocketEvent(serverUrl, msg);
expect(handlePlaybookEvents).toHaveBeenCalledWith(serverUrl, msg);
msg.event = 'foo'; // any event, even if not handled, should go through the playbooks handler
await handleWebSocketEvent(serverUrl, msg);
expect(handlePlaybookEvents).toHaveBeenCalledWith(serverUrl, msg);
});
});

View file

@ -5,6 +5,7 @@ import * as bookmark from '@actions/local/channel_bookmark';
import * as scheduledPost from '@actions/websocket/scheduled_post';
import * as calls from '@calls/connection/websocket_event_handlers';
import {WebsocketEvents} from '@constants';
import {handlePlaybookEvents} from '@playbooks/actions/websocket/events';
import * as category from './category';
import * as channel from './channel';
@ -301,4 +302,5 @@ export async function handleWebSocketEvent(serverUrl: string, msg: WebSocketMess
handleCustomProfileAttributesFieldDeletedEvent(serverUrl, msg);
break;
}
handlePlaybookEvents(serverUrl, msg);
}

View file

@ -13,6 +13,7 @@ import {loadConfigAndCalls} from '@calls/actions/calls';
import {isSupportedServerCalls} from '@calls/utils';
import DatabaseManager from '@database/manager';
import AppsManager from '@managers/apps_manager';
import {updatePlaybooksVersion} from '@playbooks/actions/remote/version';
import {getActiveServerUrl} from '@queries/app/servers';
import {getLastPostInThread} from '@queries/servers/post';
import {getConfig, getCurrentChannelId, getCurrentTeamId, setLastFullSync} from '@queries/servers/system';
@ -48,6 +49,8 @@ jest.mock('@utils/helpers', () => ({
isTablet: jest.fn().mockReturnValue(false),
}));
jest.mock('@playbooks/actions/remote/version');
describe('WebSocket Index Actions', () => {
const serverUrl = 'baseHandler.test.com';
const currentUserId = 'current-user-id';
@ -101,6 +104,7 @@ describe('WebSocket Index Actions', () => {
expect(setLastFullSync).toHaveBeenCalled();
expect(loadConfigAndCalls).toHaveBeenCalled();
expect(deferredAppEntryActions).toHaveBeenCalled();
expect(updatePlaybooksVersion).toHaveBeenCalledWith(serverUrl);
});
it('should handle error when server database not found', async () => {
@ -155,6 +159,7 @@ describe('WebSocket Index Actions', () => {
expect(openAllUnreadChannels).toHaveBeenCalled();
expect(dataRetentionCleanup).toHaveBeenCalled();
expect(AppsManager.refreshAppBindings).toHaveBeenCalled();
expect(updatePlaybooksVersion).toHaveBeenCalledWith(serverUrl);
});
it('should fetch posts for channel screen', async () => {

View file

@ -18,6 +18,7 @@ import {isSupportedServerCalls} from '@calls/utils';
import {Screens} from '@constants';
import DatabaseManager from '@database/manager';
import AppsManager from '@managers/apps_manager';
import {updatePlaybooksVersion} from '@playbooks/actions/remote/version';
import {getActiveServerUrl} from '@queries/app/servers';
import {getLastPostInThread} from '@queries/servers/post';
import {
@ -91,6 +92,9 @@ async function doReconnect(serverUrl: string, groupLabel?: BaseRequestGroupLabel
const license = await getLicense(database);
const config = await getConfig(database);
// Set the version of the playbooks plugin to the systems table
updatePlaybooksVersion(serverUrl);
if (isSupportedServerCalls(config?.Version)) {
loadConfigAndCalls(serverUrl, currentUserId, groupLabel);
}

View file

@ -3,6 +3,7 @@
import ClientCalls, {type ClientCallsMix} from '@calls/client/rest';
import ClientPlugins, {type ClientPluginsMix} from '@client/rest/plugins';
import ClientPlaybooks, {type ClientPlaybooksMix} from '@playbooks/client/rest';
import mix from '@utils/mix';
import ClientApps, {type ClientAppsMix} from './apps';
@ -49,7 +50,7 @@ interface Client extends ClientBase,
ClientPluginsMix,
ClientNPSMix,
ClientCustomAttributesMix,
ClientScheduledPostMix
ClientPlaybooksMix
{}
class Client extends mix(ClientBase).with(
@ -74,6 +75,7 @@ class Client extends mix(ClientBase).with(
ClientNPS,
ClientCustomAttributes,
ClientScheduledPost,
ClientPlaybooks,
) {
// eslint-disable-next-line no-useless-constructor
constructor(apiClient: APIClientInterface, serverUrl: string, bearerToken?: string, csrfToken?: string) {

View file

@ -365,6 +365,7 @@ describe('ClientTracking', () => {
});
it('should handle server version changes without cache control', async () => {
const emitSpy = jest.spyOn(DeviceEventEmitter, 'emit');
apiClientMock.get.mockResolvedValue({
ok: true,
data: {success: true},
@ -379,7 +380,7 @@ describe('ClientTracking', () => {
});
expect(client.serverVersion).toBe('5.30.0');
expect(DeviceEventEmitter.emit).toHaveBeenCalledWith(
expect(emitSpy).toHaveBeenCalledWith(
Events.SERVER_VERSION_CHANGED,
{serverUrl: 'https://example.com', serverVersion: '5.30.0'},
);

View file

@ -8,7 +8,9 @@ import {renderWithIntl} from '@test/intl-test-helper';
import AppVersion from './index';
describe('@components/app_version', () => {
it('should match snapshot', () => {
// Skipping this test because the snapshot became too big and
// it errors out.
it.skip('should match snapshot', () => {
const wrapper = renderWithIntl(<AppVersion/>);
expect(wrapper.toJSON()).toMatchSnapshot();
});

View file

@ -4,6 +4,7 @@
import {fireEvent} from '@testing-library/react-native';
import React from 'react';
import {Preferences} from '@constants';
import {renderWithIntlAndTheme} from '@test/intl-test-helper';
import BaseChip from './base_chip';
@ -119,4 +120,58 @@ describe('BaseChip', () => {
expect(queryByTestId('base_chip.chip_button')).toBeNull();
});
it('should bold the text when boldText is true', () => {
const {getByText, rerender} = renderWithIntlAndTheme(
<BaseChip
label='Test Label'
testID='base_chip'
boldText={true}
/>,
);
expect(getByText('Test Label')).toHaveStyle({fontWeight: '600'});
rerender(
<BaseChip
label='Test Label'
testID='base_chip'
boldText={false}
/>,
);
expect(getByText('Test Label')).toHaveStyle({fontWeight: '400'});
});
it('should use the correct text color based on the type', () => {
const {getByText, rerender} = renderWithIntlAndTheme(
<BaseChip
label='Test Label'
testID='base_chip'
type='normal'
/>,
);
expect(getByText('Test Label')).toHaveStyle({color: Preferences.THEMES.denim.centerChannelColor});
rerender(
<BaseChip
label='Test Label'
testID='base_chip'
type='danger'
/>,
);
expect(getByText('Test Label')).toHaveStyle({color: Preferences.THEMES.denim.errorTextColor});
rerender(
<BaseChip
label='Test Label'
testID='base_chip'
type='link'
/>,
);
expect(getByText('Test Label')).toHaveStyle({color: Preferences.THEMES.denim.linkColor});
});
});

View file

@ -22,6 +22,8 @@ type SelectedChipProps = {
label: string;
prefix?: JSX.Element;
maxWidth?: number;
type?: 'normal' | 'link' | 'danger';
boldText?: boolean;
}
const FADE_DURATION = 100;
@ -37,10 +39,19 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.08),
padding: 2,
},
dangerContainer: {
backgroundColor: changeOpacity(theme.dndIndicator, 0.08),
},
text: {
color: theme.centerChannelColor,
...typography('Body', 100),
},
linkText: {
color: theme.linkColor,
},
dangerText: {
color: theme.dndIndicator,
},
remove: {
justifyContent: 'center',
marginLeft: 5,
@ -50,6 +61,9 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
flexDirection: 'row',
alignItems: 'center',
},
boldText: {
...typography('Body', 100, 'SemiBold'),
},
};
});
@ -61,6 +75,8 @@ export default function BaseChip({
label,
prefix,
maxWidth,
type = 'normal',
boldText = false,
}: SelectedChipProps) {
const theme = useTheme();
const style = getStyleFromTheme(theme);
@ -71,8 +87,20 @@ export default function BaseChip({
const textMaxWidth = maxWidth || dimensions.width * 0.70;
const marginRight = showRemoveOption ? undefined : 7;
const marginLeft = prefix ? 5 : 7;
return [style.text, {maxWidth: textMaxWidth, marginRight, marginLeft}];
}, [maxWidth, dimensions.width, showRemoveOption, style.text, prefix]);
return [
style.text,
{maxWidth: textMaxWidth, marginRight, marginLeft},
type === 'link' && style.linkText,
type === 'danger' && style.dangerText,
boldText && style.boldText,
];
}, [maxWidth, dimensions.width, showRemoveOption, prefix, style.text, style.linkText, style.dangerText, style.boldText, type, boldText]);
const containerStyle = useMemo(() => {
return [
style.container,
type === 'danger' && style.dangerContainer,
];
}, [style.container, style.dangerContainer, type]);
const chipContent = (
<>
@ -123,7 +151,7 @@ export default function BaseChip({
<Animated.View
entering={showAnimation ? FadeIn.duration(FADE_DURATION) : undefined}
exiting={useFadeOut ? FadeOut.duration(FADE_DURATION) : undefined}
style={style.container}
style={containerStyle}
testID={testID}
>
{content}

View file

@ -4,12 +4,12 @@
import {withObservables} from '@nozbe/watermelondb/react';
import {withServerUrl} from '@context/server';
import websocket_manager from '@managers/websocket_manager';
import WebsocketManager from '@managers/websocket_manager';
import ConnectionBanner from './connection_banner';
const enhanced = withObservables(['serverUrl'], ({serverUrl}: {serverUrl: string}) => ({
websocketState: websocket_manager.observeWebsocketState(serverUrl),
websocketState: WebsocketManager.observeWebsocketState(serverUrl),
}));
export default withServerUrl(enhanced(ConnectionBanner));

View file

@ -79,4 +79,48 @@ describe('Friendly Date', () => {
);
expect(yearsAgoText.getByText('2 years ago')).toBeTruthy();
});
it('should render correctly with times in the future', () => {
const justNow = new Date();
justNow.setSeconds(justNow.getSeconds() + 10);
const justNowText = renderWithIntl(
<FriendlyDate value={justNow}/>,
);
expect(justNowText.getByText('Now')).toBeTruthy();
const inMinutes = new Date();
inMinutes.setMinutes(inMinutes.getMinutes() + 2);
const inMinutesText = renderWithIntl(
<FriendlyDate value={inMinutes}/>,
);
expect(inMinutesText.getByText('in 2 mins')).toBeTruthy();
const inHours = new Date();
inHours.setHours(inHours.getHours() + 2);
const inHoursText = renderWithIntl(
<FriendlyDate value={inHours}/>,
);
expect(inHoursText.getByText('in 2 hours')).toBeTruthy();
const inDays = new Date();
inDays.setDate(inDays.getDate() + 2);
const inDaysText = renderWithIntl(
<FriendlyDate value={inDays}/>,
);
expect(inDaysText.getByText('in 2 days')).toBeTruthy();
const inMonths = new Date();
inMonths.setMonth(inMonths.getMonth() + 2);
const inMonthsText = renderWithIntl(
<FriendlyDate value={inMonths}/>,
);
expect(inMonthsText.getByText('in 2 months')).toBeTruthy();
const inYears = new Date();
inYears.setFullYear(inYears.getFullYear() + 2);
const inYearsText = renderWithIntl(
<FriendlyDate value={inYears}/>,
);
expect(inYearsText.getByText('in 2 years')).toBeTruthy();
});
});

View file

@ -28,7 +28,86 @@ export function getFriendlyDate(intl: IntlShape, inputDate: number | Date, sourc
const today = sourceDate ? new Date(sourceDate) : new Date();
const date = new Date(inputDate);
const difference = (today.getTime() - date.getTime()) / 1000;
if (difference < 0) {
return getFriendlyDateAfter(intl, -difference, date, today);
}
return getFriendlyDateBefore(intl, difference, date, today);
}
function getFriendlyDateAfter(intl: IntlShape, difference: number, date: Date, today: Date): string {
// Message: Now
if (difference < SECONDS.MINUTE) {
return intl.formatMessage({
id: 'friendly_date.now',
defaultMessage: 'Now',
});
}
// Message: Minutes
if (difference < SECONDS.HOUR) {
const minutes = Math.floor(Math.round((10 * difference) / SECONDS.MINUTE) / 10);
return intl.formatMessage({
id: 'friendly_date.mins',
defaultMessage: 'in {count} {count, plural, one {min} other {mins}}',
}, {
count: minutes,
});
}
// Message: Hours
if (difference < SECONDS.DAY) {
const hours = Math.floor(Math.round((10 * difference) / SECONDS.HOUR) / 10);
return intl.formatMessage({
id: 'friendly_date.hours',
defaultMessage: 'in {count} {count, plural, one {hour} other {hours}}',
}, {
count: hours,
});
}
// Message: Days
if (difference < SECONDS.DAYS_31) {
if (today.getDate() + 1 === date.getDate() && today.getMonth() === date.getMonth()) {
return intl.formatMessage({
id: 'friendly_date.tomorrow',
defaultMessage: 'Tomorrow',
});
}
const completedAMonth = today.getMonth() !== date.getMonth() && today.getDate() <= date.getDate();
if (!completedAMonth) {
const days = Math.floor(Math.round((10 * difference) / SECONDS.DAY) / 10) || 1;
return intl.formatMessage({
id: 'friendly_date.days',
defaultMessage: 'in {count} {count, plural, one {day} other {days}}',
}, {
count: days,
});
}
}
// Message: Months
if (difference < SECONDS.DAYS_366) {
const months = Math.floor(Math.round((10 * difference) / SECONDS.DAYS_30) / 10) || 1;
return intl.formatMessage({
id: 'friendly_date.months',
defaultMessage: 'in {count} {count, plural, one {month} other {months}}',
}, {
count: months,
});
}
// Message: Years
const years = Math.floor(Math.round((10 * difference) / SECONDS.DAYS_365) / 10) || 1;
return intl.formatMessage({
id: 'friendly_date.years',
defaultMessage: 'in {count} {count, plural, one {year} other {years}}',
}, {
count: years,
});
}
function getFriendlyDateBefore(intl: IntlShape, difference: number, date: Date, today: Date): string {
// Message: Now
if (difference < SECONDS.MINUTE) {
return intl.formatMessage({

View file

@ -0,0 +1,51 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {render} from '@testing-library/react-native';
import React, {type ComponentProps} from 'react';
import {Preferences} from '@constants';
import Header from './header';
describe('Header', () => {
const getBaseProps = (): ComponentProps<typeof Header> => ({
defaultHeight: 0,
hasSearch: false,
isLargeTitle: false,
heightOffset: 0,
theme: Preferences.THEMES.denim,
});
it('right buttons are rendered with count', () => {
const props = getBaseProps();
props.rightButtons = [
{
iconName: 'bell',
count: 123,
onPress: jest.fn(),
testID: 'test-button',
},
];
const {getByTestId, rerender} = render(<Header {...props}/>);
let button = getByTestId('test-button');
expect(button).toHaveTextContent('123');
props.rightButtons = [
{
iconName: 'bell',
count: undefined,
onPress: jest.fn(),
testID: 'test-button',
},
];
rerender(<Header {...props}/>);
button = getByTestId('test-button');
expect(button).toBeOnTheScreen();
expect(button).not.toHaveTextContent('123');
expect(button).not.toHaveTextContent('0');
expect(button).not.toHaveTextContent('undefined');
});
});

View file

@ -17,6 +17,7 @@ export type HeaderRightButton = {
buttonType?: 'native' | 'opacity' | 'highlight';
color?: string;
iconName: string;
count?: number;
onPress: () => void;
rippleRadius?: number;
testID?: string;
@ -115,8 +116,13 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
},
}),
},
rightButtonContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
rightIcon: {
marginLeft: 10,
padding: 5,
},
title: {
color: theme.sidebarHeaderTextColor,
@ -173,7 +179,7 @@ const Header = ({
const additionalTitleStyle = useMemo(() => ({
marginLeft: Platform.select({android: showBackButton && !leftComponent ? 20 : 0}),
}), [leftComponent, showBackButton, theme]);
}), [leftComponent, showBackButton]);
return (
<Animated.View style={containerStyle}>
@ -233,7 +239,7 @@ const Header = ({
</Animated.View>
<Animated.View style={styles.rightContainer}>
{Boolean(rightButtons?.length) &&
rightButtons?.map((r, i) => (
rightButtons?.map((r) => (
<TouchableWithFeedback
key={r.iconName}
borderlessRipple={r.borderless === undefined ? true : r.borderless}
@ -241,14 +247,19 @@ const Header = ({
onPress={r.onPress}
rippleRadius={r.rippleRadius || 20}
type={r.buttonType || Platform.select({android: 'native', default: 'opacity'})}
style={i > 0 && styles.rightIcon}
style={styles.rightIcon}
testID={r.testID}
>
<CompassIcon
size={24}
name={r.iconName}
color={r.color || theme.sidebarHeaderTextColor}
/>
<View style={styles.rightButtonContainer}>
<CompassIcon
size={24}
name={r.iconName}
color={r.color || theme.sidebarHeaderTextColor}
/>
{Boolean(r.count) && (
<Text style={styles.title}>{r.count}</Text>
)}
</View>
</TouchableWithFeedback>
))
}

View file

@ -0,0 +1,44 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import UserAvatarsStack from '@components/user_avatars_stack';
import {renderWithIntlAndTheme} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
import Footer from './footer';
jest.mock('@components/user_avatars_stack', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mocked(UserAvatarsStack).mockImplementation(
(props) => React.createElement('UserAvatarsStack', {testID: 'mock-user-avatars-stack', ...props}),
);
describe('Footer', () => {
const mockThread = TestHelper.fakeThreadModel({
id: 'thread-123',
replyCount: 5,
isFollowing: true,
});
const mockParticipants = [
TestHelper.fakeUserModel({id: 'user-1', username: 'user1'}),
TestHelper.fakeUserModel({id: 'user-2', username: 'user2'}),
];
const defaultProps = {
channelId: 'channel-123',
location: 'Channel' as const,
participants: mockParticipants,
teamId: 'team-123',
thread: mockThread,
};
it('should use the correct bottom sheet title for the user avatars stack', () => {
const {getByTestId} = renderWithIntlAndTheme(<Footer {...defaultProps}/>);
expect(getByTestId('mock-user-avatars-stack').props.bottomSheetTitle.defaultMessage).toBe('Thread Participants');
});
});

View file

@ -2,6 +2,7 @@
// See LICENSE.txt for license information.
import React, {useCallback, useMemo} from 'react';
import {defineMessage} from 'react-intl';
import {TouchableOpacity, View, type ViewStyle} from 'react-native';
import {updateThreadFollowing} from '@actions/remote/thread';
@ -10,7 +11,7 @@ import FormattedText from '@components/formatted_text';
import UserAvatarsStack from '@components/user_avatars_stack';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {preventDoubleTap} from '@utils/tap';
import {usePreventDoubleTap} from '@hooks/utils';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
@ -79,16 +80,18 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
};
});
const bottomSheetTitleMessage = defineMessage({id: 'mobile.participants.header', defaultMessage: 'Thread Participants'});
const Footer = ({channelId, location, participants, teamId, thread}: Props) => {
const serverUrl = useServerUrl();
const theme = useTheme();
const styles = getStyleSheet(theme);
const toggleFollow = useCallback(preventDoubleTap(() => {
const toggleFollow = usePreventDoubleTap(useCallback(() => {
if (teamId == null) {
return;
}
updateThreadFollowing(serverUrl, teamId, thread.id, !thread.isFollowing, true);
}), [thread.isFollowing]);
}, [serverUrl, teamId, thread.id, thread.isFollowing]));
let repliesComponent;
let followButton;
@ -163,6 +166,7 @@ const Footer = ({channelId, location, participants, teamId, thread}: Props) => {
location={location}
style={styles.avatarsContainer}
users={participantsList}
bottomSheetTitle={bottomSheetTitleMessage}
/>
);
}

View file

@ -47,7 +47,7 @@ const Image = ({author, forwardRef, iconSize, size, source, url}: Props) => {
backgroundColor: theme.centerChannelBg,
height: size,
width: size,
}), [size]);
}), [size, theme.centerChannelBg]);
const imgSource = useMemo(() => {
if (!author || typeof source === 'string') {
@ -56,6 +56,11 @@ const Image = ({author, forwardRef, iconSize, size, source, url}: Props) => {
const pictureUrl = buildProfileImageUrlFromUser(serverUrl, author);
return source ?? {uri: buildAbsoluteUrl(serverUrl, pictureUrl)};
// We need to pass the lastPictureUpdateAt, because changes in this
// value are used internally, and may not be followed by a change
// in the containing object (author).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [author, serverUrl, source, lastPictureUpdateAt]);
if (typeof source === 'string') {

View file

@ -0,0 +1,47 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {type ComponentProps} from 'react';
import {useIsTablet} from '@hooks/device';
import {bottomSheet} from '@screens/navigation';
import {act, fireEvent, renderWithIntlAndTheme} from '@test/intl-test-helper';
import UserAvatarsStack from '.';
jest.mock('@screens/navigation', () => ({
bottomSheet: jest.fn(),
}));
jest.mock('@hooks/device', () => ({
useIsTablet: jest.fn().mockReturnValue(false),
}));
describe('UserAvatarsStack', () => {
function getBaseProps(): ComponentProps<typeof UserAvatarsStack> {
return {
location: 'Channel',
users: [],
bottomSheetTitle: {id: 'test', defaultMessage: 'bottom sheet title test text'},
};
}
it('should use the correct bottom sheet title', async () => {
const props = getBaseProps();
jest.mocked(useIsTablet).mockReturnValueOnce(false);
const {root} = renderWithIntlAndTheme(<UserAvatarsStack {...props}/>);
await act(async () => {
fireEvent.press(root);
});
expect(bottomSheet).toHaveBeenCalledWith(expect.objectContaining({
title: props.bottomSheetTitle.defaultMessage,
}));
const Content = jest.mocked(bottomSheet).mock.calls[0][0].renderContent;
const {getByText} = renderWithIntlAndTheme(<Content/>);
expect(getByText(props.bottomSheetTitle.defaultMessage as string)).toBeVisible();
});
});

View file

@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {useIntl, type MessageDescriptor} from 'react-intl';
import {Platform, type StyleProp, Text, type TextStyle, TouchableOpacity, View, type ViewStyle} from 'react-native';
import FormattedText from '@components/formatted_text';
@ -36,6 +36,7 @@ type Props = {
overflowContainerStyle?: StyleProp<ViewStyle>;
overflowItemStyle?: StyleProp<ViewStyle>;
overflowTextStyle?: StyleProp<TextStyle>;
bottomSheetTitle: MessageDescriptor;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
@ -99,6 +100,7 @@ const UserAvatarsStack = ({
overflowContainerStyle,
overflowItemStyle,
overflowTextStyle,
bottomSheetTitle,
}: Props) => {
const theme = useTheme();
const style = getStyleSheet(theme);
@ -111,8 +113,7 @@ const UserAvatarsStack = ({
{!isTablet && (
<View style={style.listHeader}>
<FormattedText
id='mobile.participants.header'
defaultMessage={'Thread Participants'}
{...bottomSheetTitle}
style={style.listHeaderText}
/>
</View>
@ -140,10 +141,10 @@ const UserAvatarsStack = ({
renderContent,
initialSnapIndex: 1,
snapPoints,
title: intl.formatMessage({id: 'mobile.participants.header', defaultMessage: 'Thread Participants'}),
title: intl.formatMessage(bottomSheetTitle),
theme,
});
}, [users, intl, theme, isTablet, style.listHeader, style.listHeaderText, channelId, location]));
}, [users, intl, bottomSheetTitle, theme, isTablet, style.listHeader, style.listHeaderText, channelId, location]));
const displayUsers = users.slice(0, breakAt);
const overflowUsersCount = Math.min(users.length - displayUsers.length, OVERFLOW_DISPLAY_LIMIT);

View file

@ -770,6 +770,7 @@ exports[`components/channel_list_row should show results and tutorial 1`] = `
<ViewManagerAdapter_ExpoImage
backgroundColor={4294967295}
borderRadius={12}
collapsable={false}
containerViewRef={"[React.ref]"}
contentFit="cover"
contentPosition={
@ -779,6 +780,24 @@ exports[`components/channel_list_row should show results and tutorial 1`] = `
}
}
height={24}
jestAnimatedProps={
{
"value": {},
}
}
jestAnimatedStyle={
{
"value": {},
}
}
jestInlineStyle={
{
"backgroundColor": "#ffffff",
"borderRadius": 12,
"height": 24,
"width": 24,
}
}
nativeViewRef={"[React.ref]"}
onError={[Function]}
onLoad={[Function]}
@ -1092,6 +1111,7 @@ exports[`components/channel_list_row should show results no tutorial 1`] = `
<ViewManagerAdapter_ExpoImage
backgroundColor={4294967295}
borderRadius={12}
collapsable={false}
containerViewRef={"[React.ref]"}
contentFit="cover"
contentPosition={
@ -1101,6 +1121,24 @@ exports[`components/channel_list_row should show results no tutorial 1`] = `
}
}
height={24}
jestAnimatedProps={
{
"value": {},
}
}
jestAnimatedStyle={
{
"value": {},
}
}
jestInlineStyle={
{
"backgroundColor": "#ffffff",
"borderRadius": 12,
"height": 24,
"width": 24,
}
}
nativeViewRef={"[React.ref]"}
onError={[Function]}
onLoad={[Function]}
@ -1452,6 +1490,7 @@ exports[`components/channel_list_row should show results no tutorial 2 users 1`]
<ViewManagerAdapter_ExpoImage
backgroundColor={4294967295}
borderRadius={12}
collapsable={false}
containerViewRef={"[React.ref]"}
contentFit="cover"
contentPosition={
@ -1461,6 +1500,24 @@ exports[`components/channel_list_row should show results no tutorial 2 users 1`]
}
}
height={24}
jestAnimatedProps={
{
"value": {},
}
}
jestAnimatedStyle={
{
"value": {},
}
}
jestInlineStyle={
{
"backgroundColor": "#ffffff",
"borderRadius": 12,
"height": 24,
"width": 24,
}
}
nativeViewRef={"[React.ref]"}
onError={[Function]}
onLoad={[Function]}
@ -1673,6 +1730,7 @@ exports[`components/channel_list_row should show results no tutorial 2 users 1`]
<ViewManagerAdapter_ExpoImage
backgroundColor={4294967295}
borderRadius={12}
collapsable={false}
containerViewRef={"[React.ref]"}
contentFit="cover"
contentPosition={
@ -1682,6 +1740,24 @@ exports[`components/channel_list_row should show results no tutorial 2 users 1`]
}
}
height={24}
jestAnimatedProps={
{
"value": {},
}
}
jestAnimatedStyle={
{
"value": {},
}
}
jestInlineStyle={
{
"backgroundColor": "#ffffff",
"borderRadius": 12,
"height": 24,
"width": 24,
}
}
nativeViewRef={"[React.ref]"}
onError={[Function]}
onLoad={[Function]}

View file

@ -76,6 +76,7 @@ export const SYSTEM_IDENTIFIERS = {
SESSION_EXPIRATION: 'sessionExpiration',
TEAM_HISTORY: 'teamHistory',
WEBSOCKET: 'WebSocket',
PLAYBOOKS_VERSION: 'playbooks_version',
};
export const GLOBAL_IDENTIFIERS = {

View file

@ -52,6 +52,8 @@ export const ONBOARDING = 'Onboarding';
export const PDF_VIEWER = 'PdfViewer';
export const PERMALINK = 'Permalink';
export const PINNED_MESSAGES = 'PinnedMessages';
export const PLAYBOOKS_RUNS = 'PlaybookRuns';
export const PLAYBOOK_RUN = 'PlaybookRun';
export const POST_OPTIONS = 'PostOptions';
export const POST_PRIORITY_PICKER = 'PostPriorityPicker';
export const REACTIONS = 'Reactions';
@ -139,6 +141,8 @@ export default {
PDF_VIEWER,
PERMALINK,
PINNED_MESSAGES,
PLAYBOOKS_RUNS,
PLAYBOOK_RUN,
POST_OPTIONS,
POST_PRIORITY_PICKER,
REACTIONS,

View file

@ -1,7 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {t} from '@i18n';
import {defineMessages, type MessageDescriptor} from 'react-intl';
import keyMirror from '@utils/key_mirror';
export const SNACK_BAR_TYPE = keyMirror({
@ -24,6 +25,7 @@ export const SNACK_BAR_TYPE = keyMirror({
SCHEDULED_POST_CREATION_ERROR: null,
RESCHEDULED_POST: null,
DELETE_SCHEDULED_POST_ERROR: null,
PLAYBOOK_ERROR: null,
});
export const MESSAGE_TYPE = {
@ -33,101 +35,155 @@ export const MESSAGE_TYPE = {
};
export type SnackBarConfig = {
id: string;
defaultMessage: string;
message: MessageDescriptor;
iconName: string;
canUndo: boolean;
type?: typeof MESSAGE_TYPE[keyof typeof MESSAGE_TYPE];
};
const messages = defineMessages({
ADD_CHANNEL_MEMBERS: {
id: 'snack.bar.channel.members.added',
defaultMessage: '{numMembers, number} {numMembers, plural, one {member} other {members}} added',
},
CODE_COPIED: {
id: 'snack.bar.code.copied',
defaultMessage: 'Code copied to clipboard',
},
FAVORITE_CHANNEL: {
id: 'snack.bar.favorited.channel',
defaultMessage: 'This channel was favorited',
},
FOLLOW_THREAD: {
id: 'snack.bar.following.thread',
defaultMessage: 'Thread followed',
},
INFO_COPIED: {
id: 'snack.bar.info.copied',
defaultMessage: 'Info copied to clipboard',
},
LINK_COPIED: {
id: 'snack.bar.link.copied',
defaultMessage: 'Link copied to clipboard',
},
LINK_COPY_FAILED: {
id: 'gallery.copy_link.failed',
defaultMessage: 'Failed to copy link to clipboard',
},
MESSAGE_COPIED: {
id: 'snack.bar.message.copied',
defaultMessage: 'Text copied to clipboard',
},
MUTE_CHANNEL: {
id: 'snack.bar.mute.channel',
defaultMessage: 'This channel was muted',
},
REMOVE_CHANNEL_USER: {
id: 'snack.bar.remove.user',
defaultMessage: '1 member was removed from the channel',
},
TEXT_COPIED: {
id: 'snack.bar.text.copied',
defaultMessage: 'Copied to clipboard',
},
UNFAVORITE_CHANNEL: {
id: 'snack.bar.unfavorite.channel',
defaultMessage: 'This channel was unfavorited',
},
UNMUTE_CHANNEL: {
id: 'snack.bar.unmute.channel',
defaultMessage: 'This channel was unmuted',
},
UNFOLLOW_THREAD: {
id: 'snack.bar.unfollow.thread',
defaultMessage: 'Thread unfollowed',
},
PLAYBOOK_ERROR: {
id: 'snack.bar.playbook.error',
defaultMessage: 'Unable to perform action. Please try again later.',
},
});
export const SNACK_BAR_CONFIG: Record<string, SnackBarConfig> = {
ADD_CHANNEL_MEMBERS: {
id: t('snack.bar.channel.members.added'),
defaultMessage: '{numMembers, number} {numMembers, plural, one {member} other {members}} added',
message: messages.ADD_CHANNEL_MEMBERS,
iconName: 'check',
canUndo: false,
},
CODE_COPIED: {
id: t('snack.bar.code.copied'),
defaultMessage: 'Code copied to clipboard',
message: messages.CODE_COPIED,
iconName: 'content-copy',
canUndo: false,
},
FAVORITE_CHANNEL: {
id: t('snack.bar.favorited.channel'),
defaultMessage: 'This channel was favorited',
message: messages.FAVORITE_CHANNEL,
iconName: 'star',
canUndo: true,
},
FOLLOW_THREAD: {
id: t('snack.bar.following.thread'),
defaultMessage: 'Thread followed',
message: messages.FOLLOW_THREAD,
iconName: 'check',
canUndo: true,
},
INFO_COPIED: {
id: t('snack.bar.info.copied'),
defaultMessage: 'Info copied to clipboard',
message: messages.INFO_COPIED,
iconName: 'content-copy',
canUndo: false,
},
LINK_COPIED: {
id: t('snack.bar.link.copied'),
defaultMessage: 'Link copied to clipboard',
message: messages.LINK_COPIED,
iconName: 'link-variant',
canUndo: false,
type: MESSAGE_TYPE.SUCCESS,
},
LINK_COPY_FAILED: {
id: t('gallery.copy_link.failed'),
defaultMessage: 'Failed to copy link to clipboard',
message: messages.LINK_COPY_FAILED,
iconName: 'link-variant',
canUndo: false,
type: MESSAGE_TYPE.ERROR,
},
MESSAGE_COPIED: {
id: t('snack.bar.message.copied'),
defaultMessage: 'Text copied to clipboard',
message: messages.MESSAGE_COPIED,
iconName: 'content-copy',
canUndo: false,
},
MUTE_CHANNEL: {
id: t('snack.bar.mute.channel'),
defaultMessage: 'This channel was muted',
message: messages.MUTE_CHANNEL,
iconName: 'bell-off-outline',
canUndo: true,
},
REMOVE_CHANNEL_USER: {
id: t('snack.bar.remove.user'),
defaultMessage: '1 member was removed from the channel',
message: messages.REMOVE_CHANNEL_USER,
iconName: 'check',
canUndo: true,
},
TEXT_COPIED: {
id: t('snack.bar.text.copied'),
defaultMessage: 'Copied to clipboard',
message: messages.TEXT_COPIED,
iconName: 'content-copy',
canUndo: false,
type: MESSAGE_TYPE.SUCCESS,
},
UNFAVORITE_CHANNEL: {
id: t('snack.bar.unfavorite.channel'),
defaultMessage: 'This channel was unfavorited',
message: messages.UNFAVORITE_CHANNEL,
iconName: 'star-outline',
canUndo: true,
},
UNMUTE_CHANNEL: {
id: t('snack.bar.unmute.channel'),
defaultMessage: 'This channel was unmuted',
message: messages.UNMUTE_CHANNEL,
iconName: 'bell-outline',
canUndo: true,
},
UNFOLLOW_THREAD: {
id: t('snack.bar.unfollow.thread'),
defaultMessage: 'Thread unfollowed',
message: messages.UNFOLLOW_THREAD,
iconName: 'check',
canUndo: true,
},
PLAYBOOK_ERROR: {
message: messages.PLAYBOOK_ERROR,
iconName: 'alert-outline',
canUndo: false,
type: MESSAGE_TYPE.ERROR,
},
};
export default {

View file

@ -106,4 +106,5 @@ const WebsocketEvents = {
CUSTOM_PROFILE_ATTRIBUTES_FIELD_CREATED: 'custom_profile_attributes_field_created',
CUSTOM_PROFILE_ATTRIBUTES_FIELD_DELETED: 'custom_profile_attributes_field_deleted',
};
export default WebsocketEvents;

View file

@ -22,6 +22,7 @@ import AppDataOperator from '@database/operator/app_data_operator';
import ServerDataOperator from '@database/operator/server_data_operator';
import {schema as appSchema} from '@database/schema/app';
import {serverSchema} from '@database/schema/server';
import {PlaybookRunModel, PlaybookChecklistModel, PlaybookChecklistItemModel} from '@playbooks/database/models';
import {deleteIOSDatabase} from '@utils/mattermost_managed';
import {urlSafeBase64Encode} from '@utils/security';
import {removeProtocol} from '@utils/url';
@ -33,7 +34,7 @@ const {SERVERS} = MM_TABLES.APP;
const APP_DATABASE = 'app';
if (__DEV__) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
logger.silence();
}
@ -53,6 +54,7 @@ class DatabaseManagerSingleton {
PostModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, ReactionModel, RoleModel,
ScheduledPostModel, SystemModel, TeamModel, TeamChannelHistoryModel, TeamMembershipModel, TeamSearchHistoryModel,
ThreadModel, ThreadParticipantModel, ThreadInTeamModel, TeamThreadsSyncModel, UserModel,
PlaybookRunModel, PlaybookChecklistModel, PlaybookChecklistItemModel,
];
this.databaseDirectory = '';
}
@ -60,6 +62,7 @@ class DatabaseManagerSingleton {
public init = async (serverUrls: string[]): Promise<void> => {
await this.createAppDatabase();
for await (const serverUrl of serverUrls) {
await this.destroyServerDatabase(serverUrl);
await this.initServerDatabase(serverUrl);
}
this.appDatabase?.operator.handleInfo({

View file

@ -23,6 +23,7 @@ import ServerDataOperator from '@database/operator/server_data_operator';
import {schema as appSchema} from '@database/schema/app';
import {serverSchema} from '@database/schema/server';
import {beforeUpgrade} from '@helpers/database/upgrade';
import {PlaybookRunModel, PlaybookChecklistModel, PlaybookChecklistItemModel} from '@playbooks/database/models';
import {getActiveServer, getServer, getServerByIdentifier} from '@queries/app/servers';
import {logDebug, logError} from '@utils/log';
import {deleteIOSDatabase, getIOSAppGroupDetails, renameIOSDatabase} from '@utils/mattermost_managed';
@ -49,6 +50,7 @@ class DatabaseManagerSingleton {
PostModel, PostsInChannelModel, PostsInThreadModel, PreferenceModel, ReactionModel, RoleModel,
ScheduledPostModel, SystemModel, TeamModel, TeamChannelHistoryModel, TeamMembershipModel, TeamSearchHistoryModel,
ThreadModel, ThreadParticipantModel, ThreadInTeamModel, TeamThreadsSyncModel, UserModel,
PlaybookRunModel, PlaybookChecklistModel, PlaybookChecklistItemModel,
];
this.databaseDirectory = Platform.OS === 'ios' ? getIOSAppGroupDetails().appGroupDatabase : `${documentDirectory}/databases/`;
@ -549,7 +551,7 @@ class DatabaseManagerSingleton {
}
if (!__DEV__) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
logger.silence();
}

View file

@ -7,10 +7,89 @@
import {addColumns, createTable, schemaMigrations, unsafeExecuteSql} from '@nozbe/watermelondb/Schema/migrations';
import {MM_TABLES} from '@constants/database';
import {PLAYBOOK_TABLES} from '@playbooks/constants/database';
const {CHANNEL_BOOKMARK, CHANNEL_INFO, DRAFT, FILE, POST, CHANNEL, CUSTOM_PROFILE_ATTRIBUTE, CUSTOM_PROFILE_FIELD, SCHEDULED_POST} = MM_TABLES.SERVER;
const {
CHANNEL,
CHANNEL_BOOKMARK,
CHANNEL_INFO,
CUSTOM_PROFILE_ATTRIBUTE,
CUSTOM_PROFILE_FIELD,
DRAFT,
FILE,
MY_CHANNEL,
POST,
SCHEDULED_POST,
} = MM_TABLES.SERVER;
const {PLAYBOOK_RUN, PLAYBOOK_CHECKLIST, PLAYBOOK_CHECKLIST_ITEM} = PLAYBOOK_TABLES;
export default schemaMigrations({migrations: [
{
toVersion: 12,
steps: [
createTable({
name: PLAYBOOK_RUN,
columns: [
{name: 'playbook_id', type: 'string'},
{name: 'name', type: 'string'},
{name: 'description', type: 'string'},
{name: 'is_active', type: 'boolean', isIndexed: true},
{name: 'owner_user_id', type: 'string'},
{name: 'team_id', type: 'string'},
{name: 'channel_id', type: 'string', isIndexed: true},
{name: 'post_id', type: 'string', isOptional: true},
{name: 'create_at', type: 'number'},
{name: 'end_at', type: 'number'},
{name: 'active_stage', type: 'number'},
{name: 'active_stage_title', type: 'string'},
{name: 'participant_ids', type: 'string'}, // JSON string
{name: 'summary', type: 'string'},
{name: 'current_status', type: 'string', isIndexed: true},
{name: 'last_status_update_at', type: 'number'},
{name: 'previous_reminder', type: 'number'},
{name: 'items_order', type: 'string'},
{name: 'retrospective_enabled', type: 'boolean'},
{name: 'retrospective', type: 'string'},
{name: 'retrospective_published_at', type: 'number'},
{name: 'update_at', type: 'number'},
],
}),
createTable({
name: PLAYBOOK_CHECKLIST,
columns: [
{name: 'run_id', type: 'string', isIndexed: true},
{name: 'items_order', type: 'string'},
{name: 'title', type: 'string'},
{name: 'update_at', type: 'number'},
],
}),
createTable({
name: PLAYBOOK_CHECKLIST_ITEM,
columns: [
{name: 'checklist_id', type: 'string', isIndexed: true},
{name: 'title', type: 'string'},
{name: 'state', type: 'string', isIndexed: true},
{name: 'state_modified', type: 'number'},
{name: 'assignee_id', type: 'string', isOptional: true},
{name: 'assignee_modified', type: 'number'},
{name: 'command', type: 'string', isOptional: true},
{name: 'command_last_run', type: 'number'},
{name: 'description', type: 'string'},
{name: 'due_date', type: 'number'},
{name: 'completed_at', type: 'number'},
{name: 'task_actions', type: 'string', isOptional: true}, // JSON string
{name: 'update_at', type: 'number'},
],
}),
addColumns({
table: MY_CHANNEL,
columns: [
{name: 'last_playbook_runs_fetch_at', type: 'number'},
],
}),
],
},
{
toVersion: 11,
steps: [

View file

@ -5,9 +5,11 @@ import {children, field, immutableRelation, json} from '@nozbe/watermelondb/deco
import Model, {type Associations} from '@nozbe/watermelondb/Model';
import {MM_TABLES} from '@constants/database';
import {PLAYBOOK_TABLES} from '@playbooks/constants/database';
import {safeParseJSON} from '@utils/helpers';
import type {Query, Relation} from '@nozbe/watermelondb';
import type PlaybookRunModel from '@playbooks/types/database/models/playbook_run';
import type CategoryChannelModel from '@typings/database/models/servers/category_channel';
import type ChannelModelInterface from '@typings/database/models/servers/channel';
import type ChannelBookmarkModel from '@typings/database/models/servers/channel_bookmark';
@ -34,6 +36,8 @@ const {
USER,
} = MM_TABLES.SERVER;
const {PLAYBOOK_RUN} = PLAYBOOK_TABLES;
/**
* The Channel model represents a channel in the Mattermost app.
*/
@ -74,6 +78,8 @@ export default class ChannelModel extends Model implements ChannelModelInterface
/** A CHANNEL is associated with one CHANNEL_INFO**/
[CHANNEL_INFO]: {type: 'has_many', foreignKey: 'id'},
/** A CHANNEL can be associated with multiple PLAYBOOK_RUN (relationship is 1:N) */
[PLAYBOOK_RUN]: {type: 'has_many', foreignKey: 'channel_id'},
};
/** create_at : The creation date for this channel */
@ -130,6 +136,9 @@ export default class ChannelModel extends Model implements ChannelModelInterface
/** postsInChannel : a section of the posts for that channel bounded by a range */
@children(POSTS_IN_CHANNEL) postsInChannel!: Query<PostsInChannelModel>;
/** playbookRuns : All playbook runs for this channel */
@children(PLAYBOOK_RUN) playbookRuns!: Query<PlaybookRunModel>;
/** team : The TEAM to which this CHANNEL belongs */
@immutableRelation(TEAM, 'team_id') team!: Relation<TeamModel>;

View file

@ -53,6 +53,9 @@ export default class MyChannelModel extends Model implements MyChannelModelInter
/** viewed_at : The timestamp showing when the user's last opened this channel (this is used for the new line message indicator) */
@field('viewed_at') viewedAt!: number;
/** last_playbook_runs_fetch_at : The timestamp of the last successful fetch of playbook runs for this channel, used as the "since" parameter for incremental updates */
@field('last_playbook_runs_fetch_at') lastPlaybookRunsFetchAt!: number;
/** channel : The relation pointing to the CHANNEL table */
@immutableRelation(CHANNEL, 'id') channel!: Relation<ChannelModel>;

View file

@ -5,7 +5,7 @@ import {Model} from '@nozbe/watermelondb';
import {field, json} from '@nozbe/watermelondb/decorators';
import {MM_TABLES} from '@constants/database';
import {safeParseJSON} from '@utils/helpers';
import {safeParseJSONStringArray} from '@utils/helpers';
import type RoleModelInterface from '@typings/database/models/servers/role';
@ -20,6 +20,6 @@ export default class RoleModel extends Model implements RoleModelInterface {
@field('name') name!: string;
/** permissions : The different permissions associated to that role */
@json('permissions', safeParseJSON) permissions!: string[];
@json('permissions', safeParseJSONStringArray) permissions!: string[];
}

View file

@ -6,7 +6,9 @@ import {children, field, immutableRelation, lazy} from '@nozbe/watermelondb/deco
import Model, {type Associations} from '@nozbe/watermelondb/Model';
import {MM_TABLES} from '@constants/database';
import {PLAYBOOK_TABLES} from '@playbooks/constants/database';
import type PlaybookRunModel from '@playbooks/types/database/models/playbook_run';
import type CategoryModel from '@typings/database/models/servers/category';
import type ChannelModel from '@typings/database/models/servers/channel';
import type MyTeamModel from '@typings/database/models/servers/my_team';
@ -28,6 +30,8 @@ const {
THREAD,
} = MM_TABLES.SERVER;
const {PLAYBOOK_RUN} = PLAYBOOK_TABLES;
/**
* A Team houses and enables communication to happen across channels and users.
*/
@ -58,6 +62,9 @@ export default class TeamModel extends Model implements TeamModelInterface {
/** A TEAM has a 1:1 relationship with TEAM_CHANNEL_HISTORY. */
[TEAM_CHANNEL_HISTORY]: {type: 'has_many', foreignKey: 'id'},
/** A TEAM can be associated with multiple PLAYBOOK_RUN (relationship is 1:N) */
[PLAYBOOK_RUN]: {type: 'has_many', foreignKey: 'team_id'},
};
/** is_allow_open_invite : Boolean flag indicating if this team is open to the public */
@ -96,6 +103,9 @@ export default class TeamModel extends Model implements TeamModelInterface {
/** channels : All the channels associated with this team */
@children(CHANNEL) channels!: Query<ChannelModel>;
/** playbookRuns : All playbook runs for this channel */
@children(PLAYBOOK_RUN) playbookRuns!: Query<PlaybookRunModel>;
/** myTeam : Retrieves additional information about the team that this user is possibly part of. */
@immutableRelation(MY_TEAM, 'id') myTeam!: Relation<MyTeamModel>;

View file

@ -5,7 +5,7 @@ import {immutableRelation, json} from '@nozbe/watermelondb/decorators';
import Model, {type Associations} from '@nozbe/watermelondb/Model';
import {MM_TABLES} from '@constants/database';
import {safeParseJSON} from '@utils/helpers';
import {safeParseJSONStringArray} from '@utils/helpers';
import type {Relation} from '@nozbe/watermelondb';
import type TeamModel from '@typings/database/models/servers/team';
@ -28,7 +28,7 @@ export default class TeamChannelHistoryModel extends Model implements TeamChanne
};
/** channel_ids : An array containing the last 5 channels visited within this team order by recency */
@json('channel_ids', safeParseJSON) channelIds!: string[];
@json('channel_ids', safeParseJSONStringArray) channelIds!: string[];
/** team : The related record from the parent Team model */
@immutableRelation(TEAM, 'id') team!: Relation<TeamModel>;

View file

@ -11,6 +11,7 @@ import TeamHandler, {type TeamHandlerMix} from '@database/operator/server_data_o
import TeamThreadsSyncHandler, {type TeamThreadsSyncHandlerMix} from '@database/operator/server_data_operator/handlers/team_threads_sync';
import ThreadHandler, {type ThreadHandlerMix} from '@database/operator/server_data_operator/handlers/thread';
import UserHandler, {type UserHandlerMix} from '@database/operator/server_data_operator/handlers/user';
import PlaybookHandler, {type PlaybookHandlerMix} from '@playbooks/database/operators/handlers';
import mix from '@utils/mix';
import type {Database} from '@nozbe/watermelondb';
@ -20,6 +21,7 @@ interface ServerDataOperator extends
ChannelHandlerMix,
CustomProfileHandlerMix,
GroupHandlerMix,
PlaybookHandlerMix,
PostHandlerMix,
ServerDataOperatorBase,
TeamHandlerMix,
@ -33,6 +35,7 @@ class ServerDataOperator extends mix(ServerDataOperatorBase).with(
ChannelHandler,
CustomProfileHandler,
GroupHandler,
PlaybookHandler,
PostHandler,
TeamHandler,
ThreadHandler,

View file

@ -155,6 +155,7 @@ export const transformMyChannelRecord = async ({action, database, value}: Transf
myChannel.lastViewedAt = raw.last_viewed_at;
myChannel.viewedAt = record?.viewedAt || 0;
myChannel.lastFetchedAt = record?.lastFetchedAt || 0;
myChannel.lastPlaybookRunsFetchAt = record?.lastPlaybookRunsFetchAt || 0;
};
return prepareBaseRecord({

View file

@ -0,0 +1,125 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import DatabaseManager from '@database/manager';
import {PLAYBOOK_TABLES} from '@playbooks/constants/database';
import TestHelper from '@test/test_helper';
import {isHasManyAssociation, prepareDestroyPermanentlyChildrenAssociatedRecords} from './general';
import type ServerDataOperator from '../server_data_operator';
import type {AssociationInfo} from '@nozbe/watermelondb/Model';
const {PLAYBOOK_RUN, PLAYBOOK_CHECKLIST, PLAYBOOK_CHECKLIST_ITEM} = PLAYBOOK_TABLES;
describe('isHasManyAssociation', () => {
it('should return true for a valid has_many association', () => {
const association: AssociationInfo = {
type: 'has_many',
foreignKey: 'user_id',
};
expect(isHasManyAssociation(association)).toBe(true);
});
it('should return false for an association without type "has_many"', () => {
const association: AssociationInfo = {
type: 'belongs_to',
key: 'user_id',
};
expect(isHasManyAssociation(association)).toBe(false);
});
it('should return false for an association without a foreignKey', () => {
// @ts-expect-error foreginKey is missing
const association: AssociationInfo = {
type: 'has_many',
};
expect(isHasManyAssociation(association)).toBe(false);
});
it('should return false for an invalid association object', () => {
const association = {
type: 'has_many',
};
expect(isHasManyAssociation(association as AssociationInfo)).toBe(false);
});
});
describe('prepareDestroyPermanentlyChildrenAssociatedRecords', () => {
let operator: ServerDataOperator;
beforeEach(async () => {
await DatabaseManager.init(['test.server.com']);
operator = DatabaseManager.serverDatabases['test.server.com']!.operator;
});
afterEach(async () => {
await DatabaseManager.destroyServerDatabase('test.server.com');
});
it('should prepare associated records for permanent deletion', async () => {
// Create playbook runs with associated checklists and items
const mockRuns = TestHelper.createPlaybookRuns(1, 1, 2); // 1 run, 1 checklist, 2 items
await operator.handlePlaybookRun({
runs: mockRuns,
prepareRecordsOnly: false,
processChildren: true,
});
// Fetch the playbook run record
const playbookRunRecord = await operator.database.get(PLAYBOOK_RUN).find(mockRuns[0].id);
// Call the function under test
const result = await prepareDestroyPermanentlyChildrenAssociatedRecords([playbookRunRecord]);
// Fetch the checklist records
const checklistRecords = await operator.database.collections.get(PLAYBOOK_CHECKLIST).query().fetch();
// Fetch the checklist item records
const checklistItemRecords = await operator.database.get(PLAYBOOK_CHECKLIST_ITEM).query().fetch();
// Verify that all checklists and items were prepared for permanent deletion
const expectedPreparedRecords = [
...checklistRecords.map((checklist) => checklist.prepareDestroyPermanently()),
...checklistItemRecords.map((item) => item.prepareDestroyPermanently()),
];
expect(result).toHaveLength(expectedPreparedRecords.length);
expectedPreparedRecords.forEach((expectedRecord) => {
const matchingRecord = result.find((record) => record.id === expectedRecord.id);
expect(matchingRecord).toBeDefined();
expect(matchingRecord).toEqual(expectedRecord);
});
});
it('should handle records with no associations', async () => {
// Create a playbook run without any checklists or items
const mockItem: PartialChecklistItem = {
...TestHelper.createPlaybookItem('playbook_run_1-checklist_1', 1),
checklist_id: 'checklist_1',
};
await operator.handlePlaybookChecklistItem({
items: [mockItem],
prepareRecordsOnly: false,
});
// Fetch the playbook run record
const playbookItemRecord = await operator.database.get(PLAYBOOK_CHECKLIST_ITEM).find(mockItem.id);
// Call the function under test
const result = await prepareDestroyPermanentlyChildrenAssociatedRecords([playbookItemRecord]);
// Verify the results
expect(result).toEqual([]);
});
it('should handle empty records array', async () => {
const result = await prepareDestroyPermanentlyChildrenAssociatedRecords([]);
expect(result).toEqual([]);
});
});

View file

@ -1,9 +1,11 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Q, type Model} from '@nozbe/watermelondb';
import {MM_TABLES} from '@constants/database';
import type {Model} from '@nozbe/watermelondb';
import type {AssociationInfo, HasManyAssociation} from '@nozbe/watermelondb/Model';
import type {IdenticalRecordArgs, RangeOfValueArgs, RecordPair, RetrieveRecordsArgs} from '@typings/database/database';
const {CHANNEL, POST, TEAM, USER} = MM_TABLES.SERVER;
@ -91,3 +93,52 @@ export const getUniqueRawsBy = <T extends RawValue>({raws, key}: { raws: T[]; ke
export const retrieveRecords = <T extends Model>({database, tableName, condition}: RetrieveRecordsArgs) => {
return database.collections.get<T>(tableName).query(condition).fetch();
};
export function isHasManyAssociation(associated: AssociationInfo) {
return associated.type === 'has_many' && 'foreignKey' in associated;
}
/**
* prepareDestroyPermanentlyAssociatedRecords: prepares children associated records
* for permanent deletion
* @param {Model[]} records
* @returns {Promise<Model[]>}
*/
export const prepareDestroyPermanentlyChildrenAssociatedRecords = async (records: Model[]): Promise<Model[]> => {
const associationPromises: Array<Promise<Model[]>> = [];
const recordsByTable = records.reduce((acc, record) => {
const tableName = (record.constructor as typeof Model).table;
if (!acc[tableName]) {
acc[tableName] = [];
}
acc[tableName].push(record);
return acc;
}, {} as Record<string, Model[]>);
for (const groupedRecords of Object.values(recordsByTable)) {
const associations = (groupedRecords[0].constructor as typeof Model).associations;
const promises = Object.entries(associations).
filter(([, associated]) => isHasManyAssociation(associated)).
map(async ([associationName, associated]) => {
const preparedRecords: Model[] = [];
const child = associated as HasManyAssociation; // this is a guard as we know that we are in a has_many association
const relatedRecords = await retrieveRecords({
database: groupedRecords[0].database,
tableName: associationName,
condition: Q.where(child.foreignKey, Q.oneOf(groupedRecords.map((r) => r.id))),
});
const childPreparedRecords = await prepareDestroyPermanentlyChildrenAssociatedRecords(relatedRecords);
preparedRecords.push(...childPreparedRecords);
preparedRecords.push(...relatedRecords.map((relatedRecord) => relatedRecord.prepareDestroyPermanently()));
return preparedRecords;
});
associationPromises.push(...promises);
}
const results = await Promise.all(associationPromises);
return results.flat();
};

View file

@ -3,6 +3,8 @@
import {type AppSchema, appSchema} from '@nozbe/watermelondb';
import {PlaybookRunSchema, PlaybookChecklistSchema, PlaybookChecklistItemSchema} from '@playbooks/database/schema';
import {
CategorySchema,
CategoryChannelSchema,
@ -43,7 +45,7 @@ import {
} from './table_schemas';
export const serverSchema: AppSchema = appSchema({
version: 11,
version: 12,
tables: [
CategorySchema,
CategoryChannelSchema,
@ -64,6 +66,9 @@ export const serverSchema: AppSchema = appSchema({
MyChannelSchema,
MyChannelSettingsSchema,
MyTeamSchema,
PlaybookRunSchema,
PlaybookChecklistSchema,
PlaybookChecklistItemSchema,
PostInThreadSchema,
PostSchema,
PostsInChannelSchema,

View file

@ -19,6 +19,7 @@ export default tableSchema({
{name: 'roles', type: 'string'},
{name: 'viewed_at', type: 'number'},
{name: 'last_fetched_at', type: 'number', isIndexed: true},
{name: 'last_playbook_runs_fetch_at', type: 'number'},
],
});

View file

@ -4,6 +4,7 @@
/* eslint-disable max-lines */
import {MM_TABLES} from '@constants/database';
import {PLAYBOOK_TABLES} from '@playbooks/constants/database';
import {serverSchema} from './index';
@ -46,10 +47,12 @@ const {
USER,
} = MM_TABLES.SERVER;
const {PLAYBOOK_RUN, PLAYBOOK_CHECKLIST, PLAYBOOK_CHECKLIST_ITEM} = PLAYBOOK_TABLES;
describe('*** Test schema for SERVER database ***', () => {
it('=> The SERVER SCHEMA should strictly match', () => {
expect(serverSchema).toEqual({
version: 11,
version: 12,
unsafeSql: undefined,
tables: {
[CATEGORY]: {
@ -265,6 +268,7 @@ describe('*** Test schema for SERVER database ***', () => {
roles: {name: 'roles', type: 'string'},
viewed_at: {name: 'viewed_at', type: 'number'},
last_fetched_at: {name: 'last_fetched_at', type: 'number', isIndexed: true},
last_playbook_runs_fetch_at: {name: 'last_playbook_runs_fetch_at', type: 'number'},
},
columnArray: [
{name: 'is_unread', type: 'boolean'},
@ -276,6 +280,7 @@ describe('*** Test schema for SERVER database ***', () => {
{name: 'roles', type: 'string'},
{name: 'viewed_at', type: 'number'},
{name: 'last_fetched_at', type: 'number', isIndexed: true},
{name: 'last_playbook_runs_fetch_at', type: 'number'},
],
},
[MY_CHANNEL_SETTINGS]: {
@ -430,6 +435,120 @@ describe('*** Test schema for SERVER database ***', () => {
{name: 'deleted_at', type: 'number'},
],
},
[PLAYBOOK_RUN]: {
name: PLAYBOOK_RUN,
unsafeSql: undefined,
columns: {
playbook_id: {name: 'playbook_id', type: 'string'},
name: {name: 'name', type: 'string'},
description: {name: 'description', type: 'string'},
is_active: {name: 'is_active', type: 'boolean', isIndexed: true},
owner_user_id: {name: 'owner_user_id', type: 'string'},
team_id: {name: 'team_id', type: 'string'},
channel_id: {name: 'channel_id', type: 'string', isIndexed: true},
post_id: {name: 'post_id', type: 'string', isOptional: true},
create_at: {name: 'create_at', type: 'number'},
end_at: {name: 'end_at', type: 'number'},
active_stage: {name: 'active_stage', type: 'number'},
active_stage_title: {name: 'active_stage_title', type: 'string'},
participant_ids: {name: 'participant_ids', type: 'string'}, // JSON string
summary: {name: 'summary', type: 'string'},
current_status: {name: 'current_status', type: 'string', isIndexed: true},
last_status_update_at: {name: 'last_status_update_at', type: 'number'},
retrospective_enabled: {name: 'retrospective_enabled', type: 'boolean'},
retrospective: {name: 'retrospective', type: 'string'},
retrospective_published_at: {name: 'retrospective_published_at', type: 'number'},
sync: {name: 'sync', type: 'string', isIndexed: true, isOptional: true},
last_sync_at: {name: 'last_sync_at', type: 'number', isOptional: true},
items_order: {name: 'items_order', type: 'string'},
previous_reminder: {name: 'previous_reminder', type: 'number', isOptional: true},
update_at: {name: 'update_at', type: 'number'},
},
columnArray: [
{name: 'playbook_id', type: 'string'},
{name: 'name', type: 'string'},
{name: 'description', type: 'string'},
{name: 'is_active', type: 'boolean', isIndexed: true},
{name: 'owner_user_id', type: 'string'},
{name: 'team_id', type: 'string'},
{name: 'channel_id', type: 'string', isIndexed: true},
{name: 'post_id', type: 'string', isOptional: true},
{name: 'create_at', type: 'number'},
{name: 'end_at', type: 'number'},
{name: 'active_stage', type: 'number'},
{name: 'active_stage_title', type: 'string'},
{name: 'participant_ids', type: 'string'}, // JSON string
{name: 'summary', type: 'string'},
{name: 'current_status', type: 'string', isIndexed: true},
{name: 'last_status_update_at', type: 'number'},
{name: 'retrospective_enabled', type: 'boolean'},
{name: 'retrospective', type: 'string'},
{name: 'retrospective_published_at', type: 'number'},
{name: 'sync', type: 'string', isIndexed: true, isOptional: true},
{name: 'last_sync_at', type: 'number', isOptional: true},
{name: 'previous_reminder', type: 'number', isOptional: true},
{name: 'items_order', type: 'string'},
{name: 'update_at', type: 'number'},
],
},
[PLAYBOOK_CHECKLIST]: {
name: PLAYBOOK_CHECKLIST,
unsafeSql: undefined,
columns: {
run_id: {name: 'run_id', type: 'string', isIndexed: true},
title: {name: 'title', type: 'string'},
sync: {name: 'sync', type: 'string', isIndexed: true, isOptional: true},
last_sync_at: {name: 'last_sync_at', type: 'number', isOptional: true},
items_order: {name: 'items_order', type: 'string'},
update_at: {name: 'update_at', type: 'number'},
},
columnArray: [
{name: 'run_id', type: 'string', isIndexed: true},
{name: 'title', type: 'string'},
{name: 'sync', type: 'string', isIndexed: true, isOptional: true},
{name: 'last_sync_at', type: 'number', isOptional: true},
{name: 'items_order', type: 'string'},
{name: 'update_at', type: 'number'},
],
},
[PLAYBOOK_CHECKLIST_ITEM]: {
name: PLAYBOOK_CHECKLIST_ITEM,
unsafeSql: undefined,
columns: {
checklist_id: {name: 'checklist_id', type: 'string', isIndexed: true},
title: {name: 'title', type: 'string'},
state: {name: 'state', type: 'string', isIndexed: true},
state_modified: {name: 'state_modified', type: 'number'},
assignee_id: {name: 'assignee_id', type: 'string', isOptional: true},
assignee_modified: {name: 'assignee_modified', type: 'number'},
command: {name: 'command', type: 'string', isOptional: true},
command_last_run: {name: 'command_last_run', type: 'number'},
description: {name: 'description', type: 'string'},
due_date: {name: 'due_date', type: 'number'},
completed_at: {name: 'completed_at', type: 'number'},
task_actions: {name: 'task_actions', type: 'string', isOptional: true}, // JSON string
sync: {name: 'sync', type: 'string', isIndexed: true, isOptional: true},
last_sync_at: {name: 'last_sync_at', type: 'number', isOptional: true},
update_at: {name: 'update_at', type: 'number'},
},
columnArray: [
{name: 'checklist_id', type: 'string', isIndexed: true},
{name: 'title', type: 'string'},
{name: 'state', type: 'string', isIndexed: true},
{name: 'state_modified', type: 'number'},
{name: 'assignee_id', type: 'string', isOptional: true},
{name: 'assignee_modified', type: 'number'},
{name: 'command', type: 'string', isOptional: true},
{name: 'command_last_run', type: 'number'},
{name: 'description', type: 'string'},
{name: 'due_date', type: 'number'},
{name: 'completed_at', type: 'number'},
{name: 'task_actions', type: 'string', isOptional: true}, // JSON string
{name: 'sync', type: 'string', isIndexed: true, isOptional: true},
{name: 'last_sync_at', type: 'number', isOptional: true},
{name: 'update_at', type: 'number'},
],
},
[POSTS_IN_THREAD]: {
name: POSTS_IN_THREAD,
unsafeSql: undefined,

View file

@ -8,12 +8,6 @@ import NavigationStore from '@store/navigation_store';
import useAndroidHardwareBackHandler from './android_back_handler';
jest.mock('react-native', () => ({
BackHandler: {
addEventListener: jest.fn(),
},
}));
jest.mock('@store/navigation_store', () => ({
getVisibleScreen: jest.fn(),
}));
@ -21,12 +15,13 @@ jest.mock('@store/navigation_store', () => ({
test('useAndroidHardwareBackHandler - calls callback when visible screen matches componentId', () => {
const componentId = 'About';
const callback = jest.fn();
const addEventListenerSpy = jest.spyOn(BackHandler, 'addEventListener');
(NavigationStore.getVisibleScreen as jest.Mock).mockReturnValue(componentId);
renderHook(() => useAndroidHardwareBackHandler(componentId, callback));
const hardwareBackPressHandler = (BackHandler.addEventListener as jest.Mock).mock.calls[0][1];
const hardwareBackPressHandler = addEventListenerSpy.mock.calls[0][1];
hardwareBackPressHandler();
expect(callback).toHaveBeenCalled();
@ -35,12 +30,12 @@ test('useAndroidHardwareBackHandler - calls callback when visible screen matches
test('useAndroidHardwareBackHandler - does not call callback when visible screen does not match componentId', () => {
const componentId = 'About';
const callback = jest.fn();
const addEventListenerSpy = jest.spyOn(BackHandler, 'addEventListener');
(NavigationStore.getVisibleScreen as jest.Mock).mockReturnValue('otherScreen');
renderHook(() => useAndroidHardwareBackHandler(componentId, callback));
const hardwareBackPressHandler = (BackHandler.addEventListener as jest.Mock).mock.calls[0][1];
const hardwareBackPressHandler = addEventListenerSpy.mock.calls[0][1];
hardwareBackPressHandler();
expect(callback).not.toHaveBeenCalled();
@ -50,7 +45,8 @@ test('useAndroidHardwareBackHandler - removes event listener on unmount', () =>
const componentId = 'About';
const callback = jest.fn();
const remove = jest.fn();
(BackHandler.addEventListener as jest.Mock).mockReturnValue({remove});
const addEventListenerSpy = jest.spyOn(BackHandler, 'addEventListener');
addEventListenerSpy.mockReturnValue({remove});
const {unmount} = renderHook(() => useAndroidHardwareBackHandler(componentId, callback));

View file

@ -35,32 +35,6 @@ const mockEmitter = {
}),
} as any;
jest.mock('react-native', () => {
const ReactNative = jest.requireActual('react-native');
return {
AppState: {
currentState: 'active',
addEventListener: jest.fn(() => ({
remove: jest.fn(),
})),
},
Keyboard: {
addListener: jest.fn((event, callback) => {
if (callback) {
callback({endCoordinates: {height: 300}, duration: 250});
}
return {remove: jest.fn()};
}),
},
NativeEventEmitter: jest.fn(() => mockEmitter),
Platform: {
OS: 'ios',
select: jest.fn((obj) => obj.ios),
},
View: ReactNative.View,
};
});
jest.mock('@mattermost/rnutils', () => ({
getWindowDimensions: jest.fn(() => ({height: 800, width: 600})),
isRunningInSplitView: jest.fn(() => false),

View file

@ -15,14 +15,14 @@ const HEADER_OFFSET = LARGE_HEADER_TITLE_HEIGHT - ViewConstants.DEFAULT_HEADER_H
describe('useCollapsibleHeader', () => {
const commonHookResponse = {
largeHeight: LARGE_HEADER_TITLE_HEIGHT,
scrollRef: {current: null},
scrollValue: {value: 0},
onScroll: expect.any(Function),
scrollRef: expect.any(Function),
scrollValue: expect.objectContaining({value: 0}),
onScroll: expect.any(Object),
hideHeader: expect.any(Function),
lockValue: 0,
unlock: expect.any(Function),
headerOffset: HEADER_OFFSET,
scrollEnabled: {value: true},
scrollEnabled: expect.objectContaining({value: true}),
setAutoScroll: expect.any(Function),
};

View file

@ -11,6 +11,8 @@ import WebSocketClient from '@client/websocket';
import DatabaseManager from '@database/manager';
import {getCurrentUserId} from '@queries/servers/system';
import {queryAllUsers} from '@queries/servers/user';
import EphemeralStore from '@store/ephemeral_store';
import TestHelper from '@test/test_helper';
import {logError} from '@utils/log';
import WebsocketManager from './websocket_manager';
@ -29,6 +31,7 @@ jest.mock('@database/manager');
jest.mock('@queries/servers/system');
jest.mock('@queries/servers/user');
jest.mock('@utils/log');
jest.mock('@store/ephemeral_store');
describe('WebsocketManager', () => {
let manager: typeof WebsocketManager;
@ -122,6 +125,17 @@ describe('WebsocketManager', () => {
});
});
describe('proper callbacks set', () => {
it('should remove playbooks when the reconnect callback is called', () => {
const client = manager.createClient(mockServerUrl, mockToken);
expect(client).toBeDefined();
expect(client.setReconnectCallback).toHaveBeenCalled();
jest.mocked(client.setReconnectCallback).mock.calls[0][0]();
expect(EphemeralStore.clearChannelPlaybooksSynced).toHaveBeenCalled();
});
});
describe('connection handling', () => {
beforeEach(async () => {
await manager.init(mockCredentials);
@ -178,8 +192,11 @@ describe('WebsocketManager', () => {
describe('periodic updates', () => {
beforeEach(async () => {
(getCurrentUserId as jest.Mock).mockResolvedValue('user1');
(queryAllUsers as jest.Mock).mockImplementation(() => ({fetchIds: async () => ['user1', 'user2']}));
jest.mocked(getCurrentUserId).mockResolvedValue('user1');
jest.mocked(queryAllUsers).mockImplementation(() => TestHelper.fakeQuery([
TestHelper.fakeUserModel({id: 'user1'}),
TestHelper.fakeUserModel({id: 'user2'}),
]));
await manager.init(mockCredentials);
});

View file

@ -16,6 +16,7 @@ import {General} from '@constants';
import DatabaseManager from '@database/manager';
import {getCurrentUserId} from '@queries/servers/system';
import {queryAllUsers} from '@queries/servers/user';
import EphemeralStore from '@store/ephemeral_store';
import {toMilliseconds} from '@utils/datetime';
import {isMainActivity} from '@utils/helpers';
import {logError} from '@utils/log';
@ -178,6 +179,12 @@ class WebsocketManagerSingleton {
private onReconnect = async (serverUrl: string) => {
this.startPeriodicStatusUpdates(serverUrl);
this.getConnectedSubject(serverUrl).next('connected');
// Clear playbooks synced state on reconnect.
// This is done to avoid clearing it on spotty connections
// with reliable websockets.
EphemeralStore.clearChannelPlaybooksSynced(serverUrl);
const error = await handleReconnect(serverUrl);
if (error) {
this.getClient(serverUrl)?.close(false);

View file

@ -0,0 +1,60 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {type ComponentProps} from 'react';
import UserAvatarsStack from '@components/user_avatars_stack';
import {renderWithIntlAndTheme} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
import JoinCallBanner from './join_call_banner';
// Mock the dependencies
jest.mock('@calls/actions', () => ({
dismissIncomingCall: jest.fn(),
}));
jest.mock('@calls/alerts', () => ({
leaveAndJoinWithAlert: jest.fn(),
showLimitRestrictedAlert: jest.fn(),
}));
jest.mock('@calls/state', () => ({
removeIncomingCall: jest.fn(),
setJoiningChannelId: jest.fn(),
}));
jest.mock('@components/user_avatars_stack');
jest.mocked(UserAvatarsStack).mockImplementation((props) => {
return React.createElement('UserAvatarsStack', {
...props,
testID: 'user-avatars-stack',
});
});
describe('JoinCallBanner', () => {
function getBaseProps(): ComponentProps<typeof JoinCallBanner> {
return {
channelId: 'channel-123',
callId: 'call-456',
serverUrl: 'https://test.server.com',
userModels: [
TestHelper.fakeUserModel({id: 'user-1', username: 'user1'}),
TestHelper.fakeUserModel({id: 'user-2', username: 'user2'}),
],
channelCallStartTime: Date.now() - 60000, // 1 minute ago
limitRestrictedInfo: {
limitRestricted: false,
maxParticipants: 8,
isCloudStarter: false,
},
};
}
it('should use the correct bottom sheet title', () => {
const props = getBaseProps();
const {getByTestId} = renderWithIntlAndTheme(<JoinCallBanner {...props}/>);
expect(getByTestId('user-avatars-stack').props.bottomSheetTitle.defaultMessage).toBe('Call participants');
});
});

View file

@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import React from 'react';
import {useIntl} from 'react-intl';
import {defineMessage, useIntl} from 'react-intl';
import {View, Pressable} from 'react-native';
import {dismissIncomingCall} from '@calls/actions';
@ -130,6 +130,11 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
},
}));
const callParticipantsMessage = defineMessage({
id: 'calls.join_call_avatars.bottom_sheet_title',
defaultMessage: 'Call participants',
});
const JoinCallBanner = ({
channelId,
callId,
@ -200,6 +205,7 @@ const JoinCallBanner = ({
overflowContainerStyle={style.overflowContainer}
overflowItemStyle={style.overflowItem}
overflowTextStyle={style.overflowText}
bottomSheetTitle={callParticipantsMessage}
/>
<Pressable onPress={onDismissPress}>
<View style={style.dismissContainer}>

View file

@ -0,0 +1,85 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import DatabaseManager from '@database/manager';
import {getMyChannel} from '@queries/servers/channel';
import TestHelper from '@test/test_helper';
import {updateLastPlaybookRunsFetchAt} from './channel';
import type ServerDataOperator from '@database/operator/server_data_operator';
const serverUrl = 'baseHandler.test.com';
let operator: ServerDataOperator;
const channelId = 'channelid1';
const channel: Channel = TestHelper.fakeChannel({
id: channelId,
team_id: 'teamid1',
total_msg_count: 0,
});
const channelMember: ChannelMembership = TestHelper.fakeChannelMember({
id: 'id',
channel_id: channelId,
msg_count: 0,
});
beforeEach(async () => {
await DatabaseManager.init([serverUrl]);
operator = DatabaseManager.serverDatabases[serverUrl]!.operator;
});
afterEach(async () => {
await DatabaseManager.destroyServerDatabase(serverUrl);
});
describe('updateLastPlaybookRunsFetchAt', () => {
it('should handle not found database', async () => {
const {data, error} = await updateLastPlaybookRunsFetchAt('foo', channelId, Date.now());
expect(data).toBeFalsy();
expect(error).toBeTruthy();
});
it('should handle channel not found', async () => {
const {data, error} = await updateLastPlaybookRunsFetchAt(serverUrl, 'nonexistent', Date.now());
expect(data).toBe(false);
expect(error).toBeUndefined();
});
it('should update lastPlaybookRunsFetchAt successfully', async () => {
// Setup channel and member
await operator.handleChannel({channels: [channel], prepareRecordsOnly: false});
await operator.handleMyChannel({channels: [channel], myChannels: [channelMember], prepareRecordsOnly: false});
const lastPlaybookRunsFetchAt = Date.now();
const {data, error} = await updateLastPlaybookRunsFetchAt(serverUrl, channelId, lastPlaybookRunsFetchAt);
expect(error).toBeUndefined();
expect(data).toBe(true);
// Verify the update was persisted
const myChannel = await getMyChannel(operator.database, channelId);
expect(myChannel).toBeDefined();
expect(myChannel!.lastPlaybookRunsFetchAt).toBe(lastPlaybookRunsFetchAt);
});
it('should handle database write errors', async () => {
// Setup channel and member
await operator.handleChannel({channels: [channel], prepareRecordsOnly: false});
await operator.handleMyChannel({channels: [channel], myChannels: [channelMember], prepareRecordsOnly: false});
// Mock database.write to throw an error
const originalWrite = operator.database.write;
operator.database.write = jest.fn().mockRejectedValue(new Error('Database write failed'));
const {data, error} = await updateLastPlaybookRunsFetchAt(serverUrl, channelId, Date.now());
expect(data).toBeUndefined();
expect(error).toBeTruthy();
// Restore original method
operator.database.write = originalWrite;
});
});

View file

@ -0,0 +1,25 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import DatabaseManager from '@database/manager';
import {getMyChannel} from '@queries/servers/channel';
import {logError} from '@utils/log';
export async function updateLastPlaybookRunsFetchAt(serverUrl: string, channelId: string, lastPlaybookRunsFetchAt: number) {
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const myChannel = await getMyChannel(database, channelId);
if (!myChannel) {
return {data: false};
}
await database.write(async () => {
await myChannel.update((c) => {
c.lastPlaybookRunsFetchAt = lastPlaybookRunsFetchAt;
});
});
return {data: true};
} catch (error) {
logError('Failed updateLastPlaybookRunsFetchAt', error);
return {error};
}
}

View file

@ -0,0 +1,70 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import DatabaseManager from '@database/manager';
import {getPlaybookChecklistItemById} from '@playbooks/database/queries/item';
import TestHelper from '@test/test_helper';
import {updateChecklistItem} from './checklist';
import type ServerDataOperator from '@database/operator/server_data_operator';
const serverUrl = 'baseHandler.test.com';
let operator: ServerDataOperator;
beforeEach(async () => {
await DatabaseManager.init([serverUrl]);
operator = DatabaseManager.serverDatabases[serverUrl]!.operator;
});
afterEach(async () => {
await DatabaseManager.destroyServerDatabase(serverUrl);
});
describe('updateChecklistItem', () => {
it('should handle not found database', async () => {
const {error} = await updateChecklistItem('foo', 'itemid', 'in_progress');
expect(error).toBeTruthy();
expect((error as Error).message).toContain('foo database not found');
});
it('should handle item not found', async () => {
const {error} = await updateChecklistItem(serverUrl, 'nonexistent', 'in_progress');
expect(error).toBe('Item not found');
});
it('should handle database write errors', async () => {
const checklistId = 'checklistid';
const item = TestHelper.createPlaybookItem(checklistId, 0);
await operator.handlePlaybookChecklistItem({items: [{...item, checklist_id: checklistId}], prepareRecordsOnly: false});
const originalWrite = operator.database.write;
operator.database.write = jest.fn().mockRejectedValue(new Error('Database write failed'));
const {error} = await updateChecklistItem(serverUrl, item.id, 'closed');
expect(error).toBeTruthy();
operator.database.write = originalWrite;
});
it('should handle all valid checklist item states', async () => {
const checklistId = 'checklistid';
const item = TestHelper.createPlaybookItem(checklistId, 0);
await operator.handlePlaybookChecklistItem({items: [{...item, checklist_id: checklistId}], prepareRecordsOnly: false});
const validStates: ChecklistItemState[] = ['', 'in_progress', 'closed', 'skipped'];
// Test each state
const testPromises = validStates.map(async (state) => {
const {data, error} = await updateChecklistItem(serverUrl, item.id, state);
expect(error).toBeUndefined();
expect(data).toBe(true);
const updated = await getPlaybookChecklistItemById(operator.database, item.id);
expect(updated).toBeDefined();
expect(updated!.state).toBe(state);
});
await Promise.all(testPromises);
});
});

View file

@ -0,0 +1,27 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import DatabaseManager from '@database/manager';
import {getPlaybookChecklistItemById} from '@playbooks/database/queries/item';
import {logError} from '@utils/log';
export async function updateChecklistItem(serverUrl: string, itemId: string, state: ChecklistItemState) {
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const item = await getPlaybookChecklistItemById(database, itemId);
if (!item) {
return {error: 'Item not found'};
}
await database.write(async () => {
item.update((i) => {
i.state = state;
});
});
return {data: true};
} catch (error) {
logError('failed to update checklist item', error);
return {error};
}
}

View file

@ -0,0 +1,47 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import DatabaseManager from '@database/manager';
import TestHelper from '@test/test_helper';
import {handlePlaybookRuns} from './run';
const serverUrl = 'baseHandler.test.com';
beforeEach(async () => {
await DatabaseManager.init([serverUrl]);
});
afterEach(async () => {
await DatabaseManager.destroyServerDatabase(serverUrl);
});
describe('handlePlaybookRuns', () => {
it('should handle not found database', async () => {
const {error} = await handlePlaybookRuns('foo', [], false, false);
expect(error).toBeDefined();
expect((error as Error).message).toContain('foo database not found');
});
it('should handle playbook runs successfully', async () => {
const runs = TestHelper.createPlaybookRuns(1, 1, 1);
const {data} = await handlePlaybookRuns(serverUrl, runs, false, false);
expect(data).toBeDefined();
expect(data!.length).toBe(runs.length);
expect(data![0].id).toBe(runs[0].id);
expect(data![0]._preparedState).toBe(null);
});
it('should handle prepareRecordsOnly and processChildren flags', async () => {
const runs = TestHelper.createPlaybookRuns(1, 1, 1);
const {data} = await handlePlaybookRuns(serverUrl, runs, true, true);
expect(data).toBeDefined();
expect(data!.length).toBe(3); // 1 run + 1 checklists + 1 item
expect(data![0].id).toBe(runs[0].id);
expect(data![0]._preparedState).toBe('create');
expect(data![1].id).toBe(runs[0].checklists[0].id);
expect(data![1]._preparedState).toBe('create');
expect(data![2].id).toBe(runs[0].checklists[0].items[0].id);
expect(data![2]._preparedState).toBe('create');
});
});

View file

@ -0,0 +1,20 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import DatabaseManager from '@database/manager';
import {logError} from '@utils/log';
export async function handlePlaybookRuns(serverUrl: string, runs: PlaybookRun[], prepareRecordsOnly = false, processChildren = false) {
try {
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const data = await operator.handlePlaybookRun({
runs,
prepareRecordsOnly,
processChildren,
});
return {data};
} catch (error) {
logError('failed to handle playbook runs', error);
return {error};
}
}

View file

@ -0,0 +1,49 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
import {querySystemValue} from '@queries/servers/system';
import {setPlaybooksVersion} from './version';
import type ServerDataOperator from '@database/operator/server_data_operator';
const serverUrl = 'baseHandler.test.com';
let operator: ServerDataOperator;
beforeEach(async () => {
await DatabaseManager.init([serverUrl]);
operator = DatabaseManager.serverDatabases[serverUrl]!.operator;
});
afterEach(async () => {
await DatabaseManager.destroyServerDatabase(serverUrl);
});
describe('setPlaybooksVersion', () => {
it('should handle not found database', async () => {
const {error} = await setPlaybooksVersion('foo', '1.2.3');
expect(error).toBeTruthy();
});
it('should set playbooks version successfully', async () => {
const version = '2.0.0';
const {data, error} = await setPlaybooksVersion(serverUrl, version);
expect(error).toBeUndefined();
expect(data).toBe(true);
const systemValues = await querySystemValue(operator.database, SYSTEM_IDENTIFIERS.PLAYBOOKS_VERSION);
expect(systemValues[0].value).toBe(version);
});
it('should handle operator errors', async () => {
const originalHandleSystem = operator.handleSystem;
operator.handleSystem = jest.fn().mockImplementation(() => {
throw new Error('fail');
});
const {error} = await setPlaybooksVersion(serverUrl, '3.0.0');
expect(error).toBeTruthy();
operator.handleSystem = originalHandleSystem;
});
});

View file

@ -0,0 +1,22 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
export const setPlaybooksVersion = async (serverUrl: string, version: string) => {
try {
const {operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
await operator.handleSystem({
systems: [{
id: SYSTEM_IDENTIFIERS.PLAYBOOKS_VERSION,
value: version,
}],
prepareRecordsOnly: false,
});
return {data: true};
} catch (error) {
return {error};
}
};

View file

@ -0,0 +1,100 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import DatabaseManager from '@database/manager';
import NetworkManager from '@managers/network_manager';
import {updateChecklistItem as localUpdateChecklistItem} from '@playbooks/actions/local/checklist';
import {updateChecklistItem, runChecklistItem} from './checklist';
const serverUrl = 'baseHandler.test.com';
const playbookRunId = 'playbook-run-id-1';
const itemId = 'checklist-item-id-1';
const checklistNumber = 0;
const itemNumber = 1;
const mockClient = {
setChecklistItemState: jest.fn(),
runChecklistItemSlashCommand: jest.fn(),
};
jest.mock('@playbooks/actions/local/checklist');
const throwFunc = () => {
throw Error('error');
};
beforeAll(() => {
// @ts-ignore
NetworkManager.getClient = () => mockClient;
});
beforeEach(async () => {
await DatabaseManager.init([serverUrl]);
});
afterEach(async () => {
await DatabaseManager.destroyServerDatabase(serverUrl);
});
describe('checklist', () => {
describe('updateChecklistItem', () => {
it('should handle client error', async () => {
jest.spyOn(NetworkManager, 'getClient').mockImplementationOnce(throwFunc);
const result = await updateChecklistItem(serverUrl, playbookRunId, itemId, checklistNumber, itemNumber, 'closed');
expect(result).toBeDefined();
expect(result.error).toBeDefined();
expect(localUpdateChecklistItem).not.toHaveBeenCalled();
});
it('should handle API error response', async () => {
mockClient.setChecklistItemState.mockResolvedValueOnce({error: 'API error'});
const result = await updateChecklistItem(serverUrl, playbookRunId, itemId, checklistNumber, itemNumber, 'closed');
expect(result).toBeDefined();
expect(result.error).toBe('API error');
expect(localUpdateChecklistItem).not.toHaveBeenCalled();
});
it('should update checklist item successfully', async () => {
mockClient.setChecklistItemState.mockResolvedValueOnce({});
const result = await updateChecklistItem(serverUrl, playbookRunId, itemId, checklistNumber, itemNumber, 'closed');
expect(result).toBeDefined();
expect(result.error).toBeUndefined();
expect(result.data).toBe(true);
expect(mockClient.setChecklistItemState).toHaveBeenCalledWith(playbookRunId, checklistNumber, itemNumber, 'closed');
expect(localUpdateChecklistItem).toHaveBeenCalledWith(serverUrl, itemId, 'closed');
});
});
describe('runChecklistItem', () => {
it('should handle client error', async () => {
jest.spyOn(NetworkManager, 'getClient').mockImplementationOnce(throwFunc);
const result = await runChecklistItem(serverUrl, playbookRunId, checklistNumber, itemNumber);
expect(result).toBeDefined();
expect(result.error).toBeDefined();
});
it('should handle API exception', async () => {
mockClient.runChecklistItemSlashCommand.mockImplementationOnce(throwFunc);
const result = await runChecklistItem(serverUrl, playbookRunId, checklistNumber, itemNumber);
expect(result).toBeDefined();
expect(result.error).toBeDefined();
});
it('should run checklist item successfully', async () => {
mockClient.runChecklistItemSlashCommand.mockResolvedValueOnce({});
const result = await runChecklistItem(serverUrl, playbookRunId, checklistNumber, itemNumber);
expect(result).toBeDefined();
expect(result.error).toBeUndefined();
expect(result.data).toBe(true);
expect(mockClient.runChecklistItemSlashCommand).toHaveBeenCalledWith(playbookRunId, checklistNumber, itemNumber);
});
});
});

View file

@ -0,0 +1,49 @@
// 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 {updateChecklistItem as localUpdateChecklistItem} from '@playbooks/actions/local/checklist';
import {getFullErrorMessage} from '@utils/errors';
import {logDebug} from '@utils/log';
export const updateChecklistItem = async (
serverUrl: string,
playbookRunId: string,
itemId: string,
checklistNumber: number,
itemNumber: number,
state: ChecklistItemState,
) => {
try {
const client = NetworkManager.getClient(serverUrl);
const res = await client.setChecklistItemState(playbookRunId, checklistNumber, itemNumber, state);
if (res.error) {
return {error: res.error};
}
await localUpdateChecklistItem(serverUrl, itemId, state);
return {data: true};
} catch (error) {
logDebug('error on fetchPlaybookRunsForChannel', getFullErrorMessage(error));
forceLogoutIfNecessary(serverUrl, error);
return {error};
}
};
export const runChecklistItem = async (
serverUrl: string,
playbookRunId: string,
checklistNumber: number,
itemNumber: number,
) => {
try {
const client = NetworkManager.getClient(serverUrl);
await client.runChecklistItemSlashCommand(playbookRunId, checklistNumber, itemNumber);
return {data: true};
} catch (error) {
logDebug('error on runChecklistItem', getFullErrorMessage(error));
forceLogoutIfNecessary(serverUrl, error);
return {error};
}
};

View file

@ -0,0 +1,171 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {PER_PAGE_DEFAULT} from '@client/rest/constants';
import DatabaseManager from '@database/manager';
import NetworkManager from '@managers/network_manager';
import {updateLastPlaybookRunsFetchAt} from '@playbooks/actions/local/channel';
import {handlePlaybookRuns} from '@playbooks/actions/local/run';
import {getLastPlaybookRunsFetchAt} from '@playbooks/database/queries/run';
import TestHelper from '@test/test_helper';
import {fetchPlaybookRunsForChannel, fetchFinishedRunsForChannel} from './runs';
const serverUrl = 'baseHandler.test.com';
const channelId = 'channel-id-1';
const mockPlaybookRun = TestHelper.fakePlaybookRun({channel_id: channelId});
const mockPlaybookRun2 = TestHelper.fakePlaybookRun({channel_id: channelId});
const mockClient = {
fetchPlaybookRuns: jest.fn(),
};
jest.mock('@playbooks/database/queries/run');
jest.mock('@playbooks/actions/local/run');
jest.mock('@playbooks/actions/local/channel');
const throwFunc = () => {
throw Error('error');
};
beforeAll(() => {
// @ts-ignore
NetworkManager.getClient = () => mockClient;
});
beforeEach(async () => {
await DatabaseManager.init([serverUrl]);
});
afterEach(async () => {
await DatabaseManager.destroyServerDatabase(serverUrl);
});
describe('fetchPlaybookRunsForChannel', () => {
jest.mocked(getLastPlaybookRunsFetchAt).mockResolvedValue(123);
it('should handle client error', async () => {
jest.spyOn(NetworkManager, 'getClient').mockImplementationOnce(throwFunc);
const result = await fetchPlaybookRunsForChannel(serverUrl, channelId);
expect(result).toBeDefined();
expect(result.error).toBeDefined();
});
it('should return empty runs when no data', async () => {
mockClient.fetchPlaybookRuns.mockResolvedValueOnce({
items: [],
has_more: false,
});
const result = await fetchPlaybookRunsForChannel(serverUrl, channelId);
expect(result).toBeDefined();
expect(result.error).toBeUndefined();
expect(result.runs).toEqual([]);
expect(updateLastPlaybookRunsFetchAt).not.toHaveBeenCalled();
expect(handlePlaybookRuns).not.toHaveBeenCalled();
});
it('should fetch single page of runs successfully', async () => {
mockClient.fetchPlaybookRuns.mockResolvedValueOnce({
items: [mockPlaybookRun],
has_more: false,
});
const result = await fetchPlaybookRunsForChannel(serverUrl, channelId);
expect(result).toBeDefined();
expect(result.error).toBeUndefined();
expect(result.runs).toEqual([mockPlaybookRun]);
expect(mockClient.fetchPlaybookRuns).toHaveBeenCalledTimes(1);
expect(mockClient.fetchPlaybookRuns).toHaveBeenCalledWith({
page: 0,
per_page: PER_PAGE_DEFAULT,
channel_id: channelId,
since: 124,
});
expect(updateLastPlaybookRunsFetchAt).toHaveBeenCalledWith(serverUrl, channelId, mockPlaybookRun.update_at);
expect(handlePlaybookRuns).toHaveBeenCalledWith(serverUrl, [mockPlaybookRun], false, true);
});
it('should fetch multiple pages of runs successfully', async () => {
mockClient.fetchPlaybookRuns.mockResolvedValueOnce({
items: [mockPlaybookRun],
has_more: true,
}).mockResolvedValueOnce({
items: [mockPlaybookRun2],
has_more: false,
});
const result = await fetchPlaybookRunsForChannel(serverUrl, channelId);
expect(result).toBeDefined();
expect(result.error).toBeUndefined();
expect(result.runs).toEqual([mockPlaybookRun, mockPlaybookRun2]);
expect(mockClient.fetchPlaybookRuns).toHaveBeenCalledTimes(2);
});
it('should handle fetchOnly mode', async () => {
mockClient.fetchPlaybookRuns.mockResolvedValueOnce({
items: [mockPlaybookRun],
has_more: false,
});
const result = await fetchPlaybookRunsForChannel(serverUrl, channelId, true);
expect(result).toBeDefined();
expect(result.error).toBeUndefined();
expect(result.runs).toEqual([mockPlaybookRun]);
expect(handlePlaybookRuns).not.toHaveBeenCalled();
});
});
describe('fetchFinishedRunsForChannel', () => {
it('should handle client error', async () => {
jest.spyOn(NetworkManager, 'getClient').mockImplementationOnce(throwFunc);
const result = await fetchFinishedRunsForChannel(serverUrl, channelId);
expect(result).toBeDefined();
expect(result.error).toBeDefined();
});
it('should fetch finished runs successfully', async () => {
mockClient.fetchPlaybookRuns.mockResolvedValueOnce({
items: [mockPlaybookRun],
has_more: false,
});
const result = await fetchFinishedRunsForChannel(serverUrl, channelId);
expect(result).toBeDefined();
expect(result.error).toBeUndefined();
expect(result.runs).toEqual([mockPlaybookRun]);
expect(result.has_more).toBe(false);
expect(mockClient.fetchPlaybookRuns).toHaveBeenCalledWith({
page: 0,
per_page: PER_PAGE_DEFAULT,
channel_id: channelId,
statuses: ['Finished'],
sort: 'end_at',
});
});
it('should fetch finished runs with pagination', async () => {
mockClient.fetchPlaybookRuns.mockResolvedValueOnce({
items: [mockPlaybookRun],
has_more: true,
});
const result = await fetchFinishedRunsForChannel(serverUrl, channelId, 1);
expect(result).toBeDefined();
expect(result.error).toBeUndefined();
expect(result.runs).toEqual([mockPlaybookRun]);
expect(result.has_more).toBe(true);
expect(mockClient.fetchPlaybookRuns).toHaveBeenCalledWith({
page: 1,
per_page: PER_PAGE_DEFAULT,
channel_id: channelId,
statuses: ['Finished'],
sort: 'end_at',
});
});
});

View file

@ -0,0 +1,82 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {forceLogoutIfNecessary} from '@actions/remote/session';
import {PER_PAGE_DEFAULT} from '@client/rest/constants';
import DatabaseManager from '@database/manager';
import NetworkManager from '@managers/network_manager';
import {updateLastPlaybookRunsFetchAt} from '@playbooks/actions/local/channel';
import {handlePlaybookRuns} from '@playbooks/actions/local/run';
import {getLastPlaybookRunsFetchAt} from '@playbooks/database/queries/run';
import {getMaxRunUpdateAt} from '@playbooks/utils/run';
import {getFullErrorMessage} from '@utils/errors';
import {logDebug} from '@utils/log';
type PlaybookRunsRequest = {
runs?: PlaybookRun[];
error?: unknown;
}
export const fetchPlaybookRunsForChannel = async (serverUrl: string, channelId: string, fetchOnly = false): Promise<PlaybookRunsRequest> => {
try {
const client = NetworkManager.getClient(serverUrl);
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const lastFetchAt = await getLastPlaybookRunsFetchAt(database, channelId);
let hasMore = true;
let page = 0;
const allRuns: PlaybookRun[] = [];
while (hasMore) {
// We want to call the API secuentially for pagination.
// We could use the total count to fetch all the other pages at once,
// but we feel it is an early optimization.
// eslint-disable-next-line no-await-in-loop
const {items: runs, has_more} = await client.fetchPlaybookRuns({
page,
per_page: PER_PAGE_DEFAULT,
channel_id: channelId,
since: lastFetchAt + 1,
});
hasMore = has_more;
allRuns.push(...runs);
page++;
}
if (!allRuns.length) {
return {runs: []};
}
updateLastPlaybookRunsFetchAt(serverUrl, channelId, getMaxRunUpdateAt(allRuns));
if (!fetchOnly) {
handlePlaybookRuns(serverUrl, allRuns, false, true);
}
return {runs: allRuns};
} catch (error) {
logDebug('error on fetchPlaybookRunsForChannel', getFullErrorMessage(error));
forceLogoutIfNecessary(serverUrl, error);
return {error};
}
};
export const fetchFinishedRunsForChannel = async (serverUrl: string, channelId: string, page = 0) => {
try {
const client = NetworkManager.getClient(serverUrl);
const {items: runs, has_more} = await client.fetchPlaybookRuns({
page,
per_page: PER_PAGE_DEFAULT,
channel_id: channelId,
statuses: ['Finished'],
sort: 'end_at',
});
return {runs, has_more};
} catch (error) {
logDebug('error on fetchFinishedRunsForChannel', getFullErrorMessage(error));
forceLogoutIfNecessary(serverUrl, error);
return {error};
}
};

View file

@ -0,0 +1,81 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import DatabaseManager from '@database/manager';
import NetworkManager from '@managers/network_manager';
import {setPlaybooksVersion} from '@playbooks/actions/local/version';
import {PLAYBOOKS_PLUGIN_ID} from '@playbooks/constants/plugin';
import {updatePlaybooksVersion} from './version';
const serverUrl = 'baseHandler.test.com';
const mockManifest = {
id: PLAYBOOKS_PLUGIN_ID,
version: '2.0.0',
};
jest.mock('@playbooks/actions/local/version');
const mockClient = {
getPluginsManifests: jest.fn(),
};
const throwFunc = () => {
throw Error('error');
};
beforeAll(() => {
// @ts-ignore
NetworkManager.getClient = () => mockClient;
});
beforeEach(async () => {
await DatabaseManager.init([serverUrl]);
});
afterEach(async () => {
await DatabaseManager.destroyServerDatabase(serverUrl);
});
describe('updatePlaybooksVersion', () => {
it('should handle client error', async () => {
jest.spyOn(NetworkManager, 'getClient').mockImplementationOnce(throwFunc);
const result = await updatePlaybooksVersion(serverUrl);
expect(result).toBeDefined();
expect(result.error).toBeDefined();
});
it('should update playbooks version successfully when manifest found', async () => {
mockClient.getPluginsManifests.mockResolvedValueOnce([mockManifest]);
const result = await updatePlaybooksVersion(serverUrl);
expect(result).toBeDefined();
expect(result.error).toBeUndefined();
expect(result.data).toBe(true);
expect(setPlaybooksVersion).toHaveBeenCalledWith(serverUrl, '2.0.0');
});
it('should handle when playbooks manifest not found', async () => {
mockClient.getPluginsManifests.mockResolvedValueOnce([
{id: 'other-plugin', version: '1.0.0'},
]);
const result = await updatePlaybooksVersion(serverUrl);
expect(result).toBeDefined();
expect(result.error).toBeUndefined();
expect(result.data).toBe(true);
expect(setPlaybooksVersion).toHaveBeenCalledWith(serverUrl, '');
});
it('should handle empty manifests array', async () => {
mockClient.getPluginsManifests.mockResolvedValueOnce([]);
const result = await updatePlaybooksVersion(serverUrl);
expect(result).toBeDefined();
expect(result.error).toBeUndefined();
expect(result.data).toBe(true);
expect(setPlaybooksVersion).toHaveBeenCalledWith(serverUrl, '');
});
});

View file

@ -0,0 +1,23 @@
// 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 {setPlaybooksVersion} from '@playbooks/actions/local/version';
import {PLAYBOOKS_PLUGIN_ID} from '@playbooks/constants/plugin';
import {getFullErrorMessage} from '@utils/errors';
import {logDebug} from '@utils/log';
export const updatePlaybooksVersion = async (serverUrl: string) => {
try {
const client = NetworkManager.getClient(serverUrl);
const manifests = await client.getPluginsManifests();
const manifest = manifests.find((m) => m.id === PLAYBOOKS_PLUGIN_ID);
await setPlaybooksVersion(serverUrl, manifest?.version || '');
return {data: true};
} catch (error) {
logDebug('error on isPlaybooksEnabled', getFullErrorMessage(error));
await forceLogoutIfNecessary(serverUrl, error);
return {error};
}
};

View file

@ -0,0 +1,42 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {WebsocketEvents} from '@constants';
import {WEBSOCKET_EVENTS} from '@playbooks/constants/websocket';
import {
handlePlaybookChecklistItemUpdated,
handlePlaybookChecklistUpdated,
handlePlaybookRunCreated,
handlePlaybookRunUpdated,
handlePlaybookRunUpdatedIncremental,
} from './runs';
import {handlePlaybookPluginDisabled, handlePlaybookPluginEnabled} from './version';
export async function handlePlaybookEvents(serverUrl: string, msg: WebSocketMessage) {
switch (msg.event) {
case WebsocketEvents.PLUGIN_ENABLED:
handlePlaybookPluginEnabled(serverUrl, msg.data.manifest);
break;
case WebsocketEvents.PLUGIN_DISABLED:
handlePlaybookPluginDisabled(serverUrl, msg.data.manifest);
break;
case WEBSOCKET_EVENTS.WEBSOCKET_PLAYBOOK_RUN_UPDATED:
handlePlaybookRunUpdated(serverUrl, msg);
break;
case WEBSOCKET_EVENTS.WEBSOCKET_PLAYBOOK_RUN_CREATED:
handlePlaybookRunCreated(serverUrl, msg);
break;
case WEBSOCKET_EVENTS.WEBSOCKET_PLAYBOOK_RUN_UPDATED_INCREMENTAL:
handlePlaybookRunUpdatedIncremental(serverUrl, msg);
break;
case WEBSOCKET_EVENTS.WEBSOCKET_PLAYBOOK_CHECKLIST_UPDATED:
handlePlaybookChecklistUpdated(serverUrl, msg);
break;
case WEBSOCKET_EVENTS.WEBSOCKET_PLAYBOOK_CHECKLIST_ITEM_UPDATED:
handlePlaybookChecklistItemUpdated(serverUrl, msg);
break;
default:
break;
}
}

View file

@ -0,0 +1,508 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import DatabaseManager from '@database/manager';
import {handlePlaybookRuns} from '@playbooks/actions/local/run';
import {getPlaybookChecklistById} from '@playbooks/database/queries/checklist';
import {getPlaybookRunById} from '@playbooks/database/queries/run';
import EphemeralStore from '@store/ephemeral_store';
import TestHelper from '@test/test_helper';
import {
handlePlaybookRunCreated,
handlePlaybookRunUpdated,
handlePlaybookRunUpdatedIncremental,
handlePlaybookChecklistUpdated,
handlePlaybookChecklistItemUpdated,
} from './runs';
import type ServerDataOperator from '@database/operator/server_data_operator';
const serverUrl = 'baseHandler.test.com';
const mockPlaybookList = TestHelper.createPlaybookRuns(1, 1, 1);
const mockPlaybookRun = mockPlaybookList[0];
const channelId = mockPlaybookRun.channel_id;
const playbookRunId = mockPlaybookRun.id;
const checklistId = mockPlaybookRun.checklists[0].id;
const checklistItemId = mockPlaybookRun.checklists[0].items[0].id;
jest.mock('@playbooks/actions/local/run');
jest.mock('@store/ephemeral_store');
let operator: ServerDataOperator;
beforeEach(async () => {
await DatabaseManager.init([serverUrl]);
operator = DatabaseManager.getServerDatabaseAndOperator(serverUrl).operator;
});
afterEach(async () => {
await DatabaseManager.destroyServerDatabase(serverUrl);
});
describe('handlePlaybookRunCreated', () => {
const mockWebSocketMessage = TestHelper.fakeWebsocketMessage({
data: {
payload: JSON.stringify({
playbook_run: mockPlaybookRun,
}),
},
});
it('should return early when no payload', async () => {
const msg = {...mockWebSocketMessage, data: {payload: undefined}};
await handlePlaybookRunCreated(serverUrl, msg);
expect(handlePlaybookRuns).not.toHaveBeenCalled();
});
it('should return early when payload cannot be parsed', async () => {
const msg = TestHelper.fakeWebsocketMessage({
data: {
payload: '{{',
},
});
await handlePlaybookRunCreated(serverUrl, msg);
expect(handlePlaybookRuns).not.toHaveBeenCalled();
});
it('should return early when channel is not synced', async () => {
jest.mocked(EphemeralStore.getChannelPlaybooksSynced).mockReturnValue(false);
await handlePlaybookRunCreated(serverUrl, mockWebSocketMessage);
expect(EphemeralStore.getChannelPlaybooksSynced).toHaveBeenCalledWith(serverUrl, channelId);
expect(handlePlaybookRuns).not.toHaveBeenCalled();
});
it('should handle playbook run created successfully', async () => {
jest.mocked(EphemeralStore.getChannelPlaybooksSynced).mockReturnValue(true);
await handlePlaybookRunCreated(serverUrl, mockWebSocketMessage);
expect(EphemeralStore.getChannelPlaybooksSynced).toHaveBeenCalledWith(serverUrl, channelId);
expect(handlePlaybookRuns).toHaveBeenCalledWith(serverUrl, [mockPlaybookRun], false, true);
});
});
describe('handlePlaybookRunUpdated', () => {
const mockWebSocketMessage = TestHelper.fakeWebsocketMessage({
data: {
payload: JSON.stringify(mockPlaybookRun),
},
});
it('should return early when no payload', async () => {
const msg = {...mockWebSocketMessage, data: {payload: undefined}};
await handlePlaybookRunUpdated(serverUrl, msg);
expect(handlePlaybookRuns).not.toHaveBeenCalled();
});
it('should return early when payload cannot be parsed', async () => {
const msg = {...mockWebSocketMessage, data: {payload: '{{'}};
await handlePlaybookRunUpdated(serverUrl, msg);
expect(handlePlaybookRuns).not.toHaveBeenCalled();
});
it('should return early when channel is not synced', async () => {
jest.mocked(EphemeralStore.getChannelPlaybooksSynced).mockReturnValue(false);
await handlePlaybookRunUpdated(serverUrl, mockWebSocketMessage);
expect(EphemeralStore.getChannelPlaybooksSynced).toHaveBeenCalledWith(serverUrl, channelId);
expect(handlePlaybookRuns).not.toHaveBeenCalled();
});
it('should handle playbook run updated successfully', async () => {
jest.mocked(EphemeralStore.getChannelPlaybooksSynced).mockReturnValue(true);
await handlePlaybookRunUpdated(serverUrl, mockWebSocketMessage);
expect(EphemeralStore.getChannelPlaybooksSynced).toHaveBeenCalledWith(serverUrl, channelId);
expect(handlePlaybookRuns).toHaveBeenCalledWith(serverUrl, [mockPlaybookRun], false, true);
});
});
describe('handlePlaybookRunUpdatedIncremental', () => {
let spyHandlePlaybookRun: jest.SpyInstance;
const mockPlaybookRunUpdate: PlaybookRunUpdate = {
id: playbookRunId,
playbook_run_updated_at: Date.now(),
changed_fields: {
name: 'Updated Run Name',
description: 'Updated description',
},
};
const mockWebSocketMessage = TestHelper.fakeWebsocketMessage({
data: {
payload: JSON.stringify(mockPlaybookRunUpdate),
},
});
beforeEach(() => {
spyHandlePlaybookRun = jest.spyOn(operator, 'handlePlaybookRun');
});
it('should return early when no payload', async () => {
const msg = {...mockWebSocketMessage, data: {payload: undefined}};
await handlePlaybookRunUpdatedIncremental(serverUrl, msg);
expect(spyHandlePlaybookRun).not.toHaveBeenCalled();
});
it('should return early when payload cannot be parsed', async () => {
const msg = {...mockWebSocketMessage, data: {payload: '{{'}};
await handlePlaybookRunUpdatedIncremental(serverUrl, msg);
expect(spyHandlePlaybookRun).not.toHaveBeenCalled();
});
it('should return early when changed_fields is missing', async () => {
const msg = TestHelper.fakeWebsocketMessage({
data: {
payload: JSON.stringify({
id: playbookRunId,
updated_at: Date.now(),
}),
},
});
await handlePlaybookRunUpdatedIncremental(serverUrl, msg);
expect(spyHandlePlaybookRun).not.toHaveBeenCalled();
});
it('should return early when run is not in database', async () => {
await handlePlaybookRunUpdatedIncremental(serverUrl, mockWebSocketMessage);
expect(spyHandlePlaybookRun).not.toHaveBeenCalled();
});
it('should return early when channel is not synced', async () => {
await operator.handlePlaybookRun({
prepareRecordsOnly: false,
runs: mockPlaybookList,
});
spyHandlePlaybookRun.mockClear();
jest.mocked(EphemeralStore.getChannelPlaybooksSynced).mockReturnValue(false);
await handlePlaybookRunUpdatedIncremental(serverUrl, mockWebSocketMessage);
expect(EphemeralStore.getChannelPlaybooksSynced).toHaveBeenCalledWith(serverUrl, channelId);
expect(spyHandlePlaybookRun).not.toHaveBeenCalled();
});
it('should handle playbook run incremental update successfully', async () => {
await operator.handlePlaybookRun({
prepareRecordsOnly: false,
runs: mockPlaybookList,
});
spyHandlePlaybookRun.mockClear();
jest.mocked(EphemeralStore.getChannelPlaybooksSynced).mockReturnValue(true);
await handlePlaybookRunUpdatedIncremental(serverUrl, mockWebSocketMessage);
expect(EphemeralStore.getChannelPlaybooksSynced).toHaveBeenCalledWith(serverUrl, channelId);
expect(spyHandlePlaybookRun).toHaveBeenCalled();
const storedRun = await getPlaybookRunById(operator.database, playbookRunId);
expect(storedRun?.name).toEqual(mockPlaybookRunUpdate.changed_fields.name);
expect(storedRun?.description).toEqual(mockPlaybookRunUpdate.changed_fields.description);
expect(storedRun?.updateAt).toEqual(mockPlaybookRunUpdate.playbook_run_updated_at);
});
it('should not add checklists even if they are present in the payload', async () => {
await operator.handlePlaybookRun({
prepareRecordsOnly: false,
runs: mockPlaybookList,
processChildren: false,
});
spyHandlePlaybookRun.mockClear();
jest.mocked(EphemeralStore.getChannelPlaybooksSynced).mockReturnValue(true);
const msg = TestHelper.fakeWebsocketMessage({
data: {
payload: JSON.stringify({
id: playbookRunId,
updated_at: Date.now(),
changed_fields: {
checklists: {
...mockPlaybookRun.checklists[0],
name: 'Updated Checklist',
},
},
}),
},
});
await handlePlaybookRunUpdatedIncremental(serverUrl, msg);
expect(spyHandlePlaybookRun).toHaveBeenCalled();
const checklist = await getPlaybookChecklistById(operator.database, mockPlaybookRun.checklists[0].id);
expect(checklist).toBeUndefined();
});
});
describe('handlePlaybookChecklistUpdated', () => {
let spyHandlePlaybookChecklist: jest.SpyInstance;
let spyHandlePlaybookChecklistItem: jest.SpyInstance;
const mockChecklistUpdate: PlaybookChecklistUpdate = {
id: checklistId,
index: 0,
checklist_updated_at: Date.now(),
fields: {
title: 'Updated Checklist',
},
item_inserts: [
TestHelper.fakePlaybookChecklistItem(checklistId, {id: checklistItemId}),
],
};
const mockPlaybookChecklistUpdatePayload: PlaybookChecklistUpdatePayload = {
playbook_run_id: playbookRunId,
update: mockChecklistUpdate,
};
const mockWebSocketMessage = TestHelper.fakeWebsocketMessage({
data: {
payload: JSON.stringify(mockPlaybookChecklistUpdatePayload),
},
});
beforeEach(() => {
spyHandlePlaybookChecklist = jest.spyOn(operator, 'handlePlaybookChecklist');
spyHandlePlaybookChecklistItem = jest.spyOn(operator, 'handlePlaybookChecklistItem');
});
it('should return early when no payload', async () => {
const msg = {...mockWebSocketMessage, data: {payload: undefined}};
await handlePlaybookChecklistUpdated(serverUrl, msg);
expect(spyHandlePlaybookChecklist).not.toHaveBeenCalled();
expect(spyHandlePlaybookChecklistItem).not.toHaveBeenCalled();
});
it('should return early when payload cannot be parsed', async () => {
const msg = TestHelper.fakeWebsocketMessage({
data: {
payload: '{{',
},
});
await handlePlaybookChecklistUpdated(serverUrl, msg);
expect(spyHandlePlaybookChecklist).not.toHaveBeenCalled();
expect(spyHandlePlaybookChecklistItem).not.toHaveBeenCalled();
});
it('should return early when update.fields is missing', async () => {
const msg = TestHelper.fakeWebsocketMessage({
data: {
payload: JSON.stringify({
playbook_run_id: playbookRunId,
update: {id: checklistId, index: 0, updated_at: Date.now()},
}),
},
});
await handlePlaybookChecklistUpdated(serverUrl, msg);
expect(spyHandlePlaybookChecklist).not.toHaveBeenCalled();
expect(spyHandlePlaybookChecklistItem).not.toHaveBeenCalled();
});
it('should return early when run is not in database', async () => {
await handlePlaybookChecklistUpdated(serverUrl, mockWebSocketMessage);
expect(spyHandlePlaybookChecklist).not.toHaveBeenCalled();
expect(spyHandlePlaybookChecklistItem).not.toHaveBeenCalled();
});
it('should return early when channel is not synced', async () => {
await operator.handlePlaybookRun({
prepareRecordsOnly: false,
runs: mockPlaybookList,
});
jest.mocked(EphemeralStore.getChannelPlaybooksSynced).mockReturnValue(false);
await handlePlaybookChecklistUpdated(serverUrl, mockWebSocketMessage);
expect(EphemeralStore.getChannelPlaybooksSynced).toHaveBeenCalledWith(serverUrl, channelId);
expect(spyHandlePlaybookChecklist).not.toHaveBeenCalled();
expect(spyHandlePlaybookChecklistItem).not.toHaveBeenCalled();
});
it('should handle checklist update successfully without item inserts', async () => {
await operator.handlePlaybookRun({
prepareRecordsOnly: false,
runs: mockPlaybookList,
});
const payloadWithoutInserts = {
...mockPlaybookChecklistUpdatePayload,
update: {
...mockChecklistUpdate,
item_inserts: undefined,
},
};
const msg = TestHelper.fakeWebsocketMessage({
data: {
payload: JSON.stringify(payloadWithoutInserts),
},
});
jest.mocked(EphemeralStore.getChannelPlaybooksSynced).mockReturnValue(true);
await handlePlaybookChecklistUpdated(serverUrl, msg);
expect(EphemeralStore.getChannelPlaybooksSynced).toHaveBeenCalledWith(serverUrl, channelId);
expect(spyHandlePlaybookChecklist).toHaveBeenCalledWith({
checklists: [{
...mockChecklistUpdate.fields,
items: undefined,
id: checklistId,
update_at: mockChecklistUpdate.checklist_updated_at,
run_id: playbookRunId,
}],
prepareRecordsOnly: false,
processChildren: false,
});
expect(spyHandlePlaybookChecklistItem).not.toHaveBeenCalled();
});
it('should handle checklist update successfully with item inserts', async () => {
await operator.handlePlaybookRun({
prepareRecordsOnly: false,
runs: mockPlaybookList,
});
jest.mocked(EphemeralStore.getChannelPlaybooksSynced).mockReturnValue(true);
await handlePlaybookChecklistUpdated(serverUrl, mockWebSocketMessage);
expect(spyHandlePlaybookChecklist).toHaveBeenCalledWith({
checklists: [{
...mockChecklistUpdate.fields,
items: undefined,
id: checklistId,
update_at: mockChecklistUpdate.checklist_updated_at,
run_id: playbookRunId,
}],
prepareRecordsOnly: false,
processChildren: false,
});
expect(spyHandlePlaybookChecklistItem).toHaveBeenCalledWith({
items: [{
...mockChecklistUpdate.item_inserts![0],
checklist_id: checklistId,
update_at: mockChecklistUpdate.checklist_updated_at,
}],
prepareRecordsOnly: false,
});
});
});
describe('handlePlaybookChecklistItemUpdated', () => {
let spyHandlePlaybookChecklistItem: jest.SpyInstance;
const mockChecklistItemUpdate: PlaybookChecklistItemUpdate = {
id: checklistItemId,
index: 0,
checklist_item_updated_at: Date.now(),
fields: {
title: 'Updated Item',
state: 'closed',
},
};
const mockPlaybookChecklistItemUpdatePayload: PlaybookChecklistItemUpdatePayload = {
playbook_run_id: playbookRunId,
checklist_id: checklistId,
update: mockChecklistItemUpdate,
};
const mockWebSocketMessage = TestHelper.fakeWebsocketMessage({
data: {
payload: JSON.stringify(mockPlaybookChecklistItemUpdatePayload),
},
});
beforeEach(() => {
spyHandlePlaybookChecklistItem = jest.spyOn(operator, 'handlePlaybookChecklistItem');
});
it('should return early when no payload', async () => {
const msg = {...mockWebSocketMessage, data: {payload: undefined}};
await handlePlaybookChecklistItemUpdated(serverUrl, msg);
expect(spyHandlePlaybookChecklistItem).not.toHaveBeenCalled();
});
it('should return early when payload cannot be parsed', async () => {
const msg = TestHelper.fakeWebsocketMessage({
data: {
payload: '{{',
},
});
await handlePlaybookChecklistItemUpdated(serverUrl, msg);
expect(spyHandlePlaybookChecklistItem).not.toHaveBeenCalled();
});
it('should return early when run is not in database', async () => {
await handlePlaybookChecklistItemUpdated(serverUrl, mockWebSocketMessage);
expect(spyHandlePlaybookChecklistItem).not.toHaveBeenCalled();
});
it('should return early when channel is not synced', async () => {
await operator.handlePlaybookRun({
prepareRecordsOnly: false,
runs: mockPlaybookList,
});
jest.mocked(EphemeralStore.getChannelPlaybooksSynced).mockReturnValue(false);
await handlePlaybookChecklistItemUpdated(serverUrl, mockWebSocketMessage);
expect(EphemeralStore.getChannelPlaybooksSynced).toHaveBeenCalledWith(serverUrl, channelId);
expect(spyHandlePlaybookChecklistItem).not.toHaveBeenCalled();
});
it('should handle checklist item update successfully', async () => {
await operator.handlePlaybookRun({
prepareRecordsOnly: false,
runs: mockPlaybookList,
});
jest.mocked(EphemeralStore.getChannelPlaybooksSynced).mockReturnValue(true);
await handlePlaybookChecklistItemUpdated(serverUrl, mockWebSocketMessage);
expect(EphemeralStore.getChannelPlaybooksSynced).toHaveBeenCalledWith(serverUrl, channelId);
expect(spyHandlePlaybookChecklistItem).toHaveBeenCalledWith({
items: [{
...mockChecklistItemUpdate.fields,
id: checklistItemId,
checklist_id: checklistId,
update_at: mockChecklistItemUpdate.checklist_item_updated_at,
}],
prepareRecordsOnly: false,
});
});
});

View file

@ -0,0 +1,168 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import DatabaseManager from '@database/manager';
import {handlePlaybookRuns} from '@playbooks/actions/local/run';
import {getPlaybookRunById} from '@playbooks/database/queries/run';
import EphemeralStore from '@store/ephemeral_store';
import {safeParseJSON} from '@utils/helpers';
const isValidEvent = (data: unknown) => {
if (!data || typeof data !== 'object') {
return false;
}
return true;
};
export const handlePlaybookRunCreated = async (serverUrl: string, msg: WebSocketMessage) => {
if (!msg.data.payload) {
return;
}
const data = safeParseJSON(msg.data.payload) as PlaybookRunCreatedPayload;
if (!isValidEvent(data)) {
return;
}
const playbookRun = data.playbook_run;
const isSynced = EphemeralStore.getChannelPlaybooksSynced(serverUrl, playbookRun.channel_id);
if (!isSynced) {
// We don't update the run because any information we currently have may be outdated
return;
}
await handlePlaybookRuns(serverUrl, [playbookRun], false, true);
};
export const handlePlaybookRunUpdated = async (serverUrl: string, msg: WebSocketMessage) => {
// Same as handlePlaybookRunCreated, but only used for non-incremental updates
if (!msg.data.payload) {
return;
}
const data = safeParseJSON(msg.data.payload) as PlaybookRun;
if (!isValidEvent(data)) {
return;
}
const playbookRun = data;
const isSynced = EphemeralStore.getChannelPlaybooksSynced(serverUrl, playbookRun.channel_id);
if (!isSynced) {
// We don't update the run because any information we currently have may be outdated
return;
}
await handlePlaybookRuns(serverUrl, [playbookRun], false, true);
};
export const handlePlaybookRunUpdatedIncremental = async (serverUrl: string, msg: WebSocketMessage) => {
if (!msg.data.payload) {
return;
}
const data = safeParseJSON(msg.data.payload) as PlaybookRunUpdate;
if (!data?.changed_fields) {
return;
}
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const run = await getPlaybookRunById(database, data.id);
if (!run) {
// Do not handle any data if the run is not in the database
return;
}
const isSynced = EphemeralStore.getChannelPlaybooksSynced(serverUrl, run.channelId);
if (!isSynced) {
// We don't update the run because any information we currently have may be outdated
return;
}
await operator.handlePlaybookRun({
runs: [{
...data.changed_fields,
checklists: undefined, // Remove the checklists from the update
id: data.id,
update_at: data.playbook_run_updated_at,
}],
prepareRecordsOnly: false,
processChildren: false,
});
};
export const handlePlaybookChecklistUpdated = async (serverUrl: string, msg: WebSocketMessage) => {
if (!msg.data.payload) {
return;
}
const data = safeParseJSON(msg.data.payload) as PlaybookChecklistUpdatePayload;
if (!data?.update?.fields && !data?.update?.items_order) {
return;
}
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const run = await getPlaybookRunById(database, data.playbook_run_id);
if (!run) {
// Do not handle any data if the run is not in the database
return;
}
const isSynced = EphemeralStore.getChannelPlaybooksSynced(serverUrl, run.channelId);
if (!isSynced) {
// We don't update the run because any information we currently have may be outdated
return;
}
await operator.handlePlaybookChecklist({
checklists: [{
...(data.update?.fields || {}),
items_order: data.update?.items_order,
items: undefined, // Remove the items from the update
id: data.update.id,
update_at: data.update.checklist_updated_at,
run_id: data.playbook_run_id,
}],
prepareRecordsOnly: false,
processChildren: false,
});
if (data.update.item_inserts) {
const promises = [];
for (const item of data.update.item_inserts) {
promises.push(operator.handlePlaybookChecklistItem({
items: [{...item, checklist_id: data.update.id, update_at: data.update.checklist_updated_at}],
prepareRecordsOnly: false,
}));
}
await Promise.all(promises);
}
};
export const handlePlaybookChecklistItemUpdated = async (serverUrl: string, msg: WebSocketMessage) => {
if (!msg.data.payload) {
return;
}
const data = safeParseJSON(msg.data.payload) as PlaybookChecklistItemUpdatePayload;
if (!isValidEvent(data)) {
return;
}
const {database, operator} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const run = await getPlaybookRunById(database, data.playbook_run_id);
if (!run) {
// Do not handle any data if the run is not in the database
return;
}
const isSynced = EphemeralStore.getChannelPlaybooksSynced(serverUrl, run.channelId);
if (!isSynced) {
// We don't update the run because any information we currently have may be outdated
return;
}
await operator.handlePlaybookChecklistItem({
items: [{...data.update.fields, id: data.update.id, checklist_id: data.checklist_id, update_at: data.update.checklist_item_updated_at}],
prepareRecordsOnly: false,
});
};

View file

@ -0,0 +1,89 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {setPlaybooksVersion} from '@playbooks/actions/local/version';
import {PLAYBOOKS_PLUGIN_ID} from '@playbooks/constants/plugin';
import {
handlePlaybookPluginEnabled,
handlePlaybookPluginDisabled,
} from './version';
const serverUrl = 'test-server.com';
jest.mock('@playbooks/actions/local/version');
describe('handlePlaybookPluginEnabled', () => {
it('should set playbooks version when plugin is enabled with correct manifest', async () => {
const manifest: ClientPluginManifest = {
id: PLAYBOOKS_PLUGIN_ID,
version: '2.0.0',
webapp: {
bundle_path: '/static/playbooks.js',
},
};
await handlePlaybookPluginEnabled(serverUrl, manifest);
expect(setPlaybooksVersion).toHaveBeenCalledWith(serverUrl, '2.0.0');
expect(setPlaybooksVersion).toHaveBeenCalledTimes(1);
});
it('should not set playbooks version when manifest id does not match playbooks plugin id', async () => {
const manifest: ClientPluginManifest = {
id: 'other-plugin',
version: '1.0.0',
webapp: {
bundle_path: '/static/other.js',
},
};
await handlePlaybookPluginEnabled(serverUrl, manifest);
expect(setPlaybooksVersion).not.toHaveBeenCalled();
});
it('should handle empty version string', async () => {
const manifest: ClientPluginManifest = {
id: PLAYBOOKS_PLUGIN_ID,
version: '',
webapp: {
bundle_path: '/static/playbooks.js',
},
};
await handlePlaybookPluginEnabled(serverUrl, manifest);
expect(setPlaybooksVersion).toHaveBeenCalledWith(serverUrl, '');
});
});
describe('handlePlaybookPluginDisabled', () => {
it('should clear playbooks version when plugin is disabled with correct manifest', async () => {
const manifest: ClientPluginManifest = {
id: PLAYBOOKS_PLUGIN_ID,
version: '2.0.0',
webapp: {
bundle_path: '/static/playbooks.js',
},
};
await handlePlaybookPluginDisabled(serverUrl, manifest);
expect(setPlaybooksVersion).toHaveBeenCalledWith(serverUrl, '');
});
it('should not clear playbooks version when manifest id does not match playbooks plugin id', async () => {
const manifest: ClientPluginManifest = {
id: 'other-plugin',
version: '1.0.0',
webapp: {
bundle_path: '/static/other.js',
},
};
await handlePlaybookPluginDisabled(serverUrl, manifest);
expect(setPlaybooksVersion).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,19 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {setPlaybooksVersion} from '@playbooks/actions/local/version';
import {PLAYBOOKS_PLUGIN_ID} from '@playbooks/constants/plugin';
export async function handlePlaybookPluginEnabled(serverUrl: string, manifest: ClientPluginManifest) {
if (manifest.id !== PLAYBOOKS_PLUGIN_ID) {
return;
}
setPlaybooksVersion(serverUrl, manifest.version);
}
export async function handlePlaybookPluginDisabled(serverUrl: string, manifest: ClientPluginManifest) {
if (manifest.id !== PLAYBOOKS_PLUGIN_ID) {
return;
}
setPlaybooksVersion(serverUrl, '');
}

View file

@ -0,0 +1,174 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import TestHelper from '@test/test_helper';
import {buildQueryString} from '@utils/helpers';
let client: any;
beforeEach(() => {
client = TestHelper.createClient();
client.doFetch = jest.fn();
});
describe('fetchPlaybookRuns', () => {
test('should fetch playbook runs with params and groupLabel', async () => {
const params = {team_id: 'team1', page: 1, per_page: 10};
const groupLabel: RequestGroupLabel = 'Cold Start';
const queryParams = buildQueryString(params);
const expectedUrl = `/plugins/playbooks/api/v0/runs${queryParams}`;
const expectedOptions = {method: 'get', groupLabel};
const mockResponse = {items: [], total_count: 0, page_count: 0, has_more: false};
jest.mocked(client.doFetch).mockResolvedValue(mockResponse);
const result = await client.fetchPlaybookRuns(params, groupLabel);
expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions);
expect(result).toEqual(mockResponse);
});
test('should fetch playbook runs with params only', async () => {
const params = {team_id: 'team1', page: 0, per_page: 10};
const queryParams = buildQueryString(params);
const expectedUrl = `/plugins/playbooks/api/v0/runs${queryParams}`;
const expectedOptions = {method: 'get', groupLabel: undefined};
const mockResponse = {items: [], total_count: 0, page_count: 0, has_more: false};
jest.mocked(client.doFetch).mockResolvedValue(mockResponse);
const result = await client.fetchPlaybookRuns(params);
expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions);
expect(result).toEqual(mockResponse);
});
test('should return default response when doFetch returns null', async () => {
const params = {team_id: 'team1', page: 0, per_page: 10};
const expectedResponse = {items: [], total_count: 0, page_count: 0, has_more: false};
jest.mocked(client.doFetch).mockResolvedValue(null);
const result = await client.fetchPlaybookRuns(params);
expect(result).toEqual(expectedResponse);
});
test('should return default response when doFetch throws error', async () => {
const params = {team_id: 'team1', page: 0, per_page: 10};
const expectedResponse = {items: [], total_count: 0, page_count: 0, has_more: false};
jest.mocked(client.doFetch).mockRejectedValue(new Error('Network error'));
const result = await client.fetchPlaybookRuns(params);
expect(result).toEqual(expectedResponse);
});
});
describe('setChecklistItemState', () => {
test('should set checklist item state successfully', async () => {
const playbookRunID = 'run123';
const checklistNum = 1;
const itemNum = 2;
const newState: ChecklistItemState = 'closed';
const expectedUrl = `/plugins/playbooks/api/v0/runs/${playbookRunID}/checklists/${checklistNum}/item/${itemNum}/state`;
const expectedOptions = {method: 'put', body: {new_state: newState}};
const mockResponse = {success: true};
jest.mocked(client.doFetch).mockResolvedValue(mockResponse);
const result = await client.setChecklistItemState(playbookRunID, checklistNum, itemNum, newState);
expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions);
expect(result).toEqual(mockResponse);
});
test('should handle error when setting checklist item state', async () => {
const playbookRunID = 'run123';
const checklistNum = 1;
const itemNum = 2;
const newState = 'in_progress' as ChecklistItemState;
const expectedError = {error: new Error('Network error')};
jest.mocked(client.doFetch).mockRejectedValue(new Error('Network error'));
const result = await client.setChecklistItemState(playbookRunID, checklistNum, itemNum, newState);
expect(result).toEqual(expectedError);
});
test('should set checklist item state with empty state', async () => {
const playbookRunID = 'run123';
const checklistNum = 0;
const itemNum = 0;
const newState: ChecklistItemState = '';
const expectedUrl = `/plugins/playbooks/api/v0/runs/${playbookRunID}/checklists/${checklistNum}/item/${itemNum}/state`;
const expectedOptions = {method: 'put', body: {new_state: newState}};
const mockResponse = {success: true};
jest.mocked(client.doFetch).mockResolvedValue(mockResponse);
const result = await client.setChecklistItemState(playbookRunID, checklistNum, itemNum, newState);
expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions);
expect(result).toEqual(mockResponse);
});
test('should set checklist item state with skipped state', async () => {
const playbookRunID = 'run123';
const checklistNum = 2;
const itemNum = 3;
const newState: ChecklistItemState = 'skipped';
const expectedUrl = `/plugins/playbooks/api/v0/runs/${playbookRunID}/checklists/${checklistNum}/item/${itemNum}/state`;
const expectedOptions = {method: 'put', body: {new_state: newState}};
const mockResponse = {success: true};
jest.mocked(client.doFetch).mockResolvedValue(mockResponse);
const result = await client.setChecklistItemState(playbookRunID, checklistNum, itemNum, newState);
expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions);
expect(result).toEqual(mockResponse);
});
});
describe('runChecklistItemSlashCommand', () => {
test('should run checklist item slash command successfully', async () => {
const playbookRunId = 'run123';
const checklistNumber = 1;
const itemNumber = 2;
const expectedUrl = `/plugins/playbooks/api/v0/runs/${playbookRunId}/checklists/${checklistNumber}/item/${itemNumber}/run`;
const expectedOptions = {method: 'post'};
jest.mocked(client.doFetch).mockResolvedValue(undefined);
await client.runChecklistItemSlashCommand(playbookRunId, checklistNumber, itemNumber);
expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions);
});
test('should run checklist item slash command with zero indices', async () => {
const playbookRunId = 'run123';
const checklistNumber = 0;
const itemNumber = 0;
const expectedUrl = `/plugins/playbooks/api/v0/runs/${playbookRunId}/checklists/${checklistNumber}/item/${itemNumber}/run`;
const expectedOptions = {method: 'post'};
jest.mocked(client.doFetch).mockResolvedValue(undefined);
await client.runChecklistItemSlashCommand(playbookRunId, checklistNumber, itemNumber);
expect(client.doFetch).toHaveBeenCalledWith(expectedUrl, expectedOptions);
});
test('should handle error when running checklist item slash command', async () => {
const playbookRunId = 'run123';
const checklistNumber = 1;
const itemNumber = 2;
jest.mocked(client.doFetch).mockRejectedValue(new Error('Network error'));
await expect(client.runChecklistItemSlashCommand(playbookRunId, checklistNumber, itemNumber)).rejects.toThrow('Network error');
});
});

View file

@ -0,0 +1,111 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {buildQueryString} from '@utils/helpers';
import type ClientBase from '@client/rest/base';
export interface ClientPlaybooksMix {
// Playbook Runs
fetchPlaybookRuns: (params: FetchPlaybookRunsParams, groupLabel?: RequestGroupLabel) => Promise<FetchPlaybookRunsReturn>;
// fetchPlaybookRun: (id: string, groupLabel?: RequestGroupLabel) => Promise<PlaybookRun>;
// Run Management
// finishRun: (playbookRunId: string) => Promise<any>;
// Checklist Management
setChecklistItemState: (playbookRunID: string, checklistNum: number, itemNum: number, newState: ChecklistItemState) => Promise<any>;
// skipChecklistItem: (playbookRunID: string, checklistNum: number, itemNum: number) => Promise<void>;
// skipChecklist: (playbookRunID: string, checklistNum: number) => Promise<void>;
// Slash Commands
runChecklistItemSlashCommand: (playbookRunId: string, checklistNumber: number, itemNumber: number) => Promise<void>;
}
const ClientPlaybooks = <TBase extends Constructor<ClientBase>>(superclass: TBase) => class extends superclass {
// Route generators
getPlaybooksRoute = () => {
return '/plugins/playbooks/api/v0';
};
getPlaybookRunsRoute = () => {
return `${this.getPlaybooksRoute()}/runs`;
};
getPlaybookRunRoute = (runId: string) => {
return `${this.getPlaybookRunsRoute()}/${runId}`;
};
// Playbook Runs
fetchPlaybookRuns = async (params: FetchPlaybookRunsParams, groupLabel?: RequestGroupLabel) => {
const queryParams = buildQueryString(params);
try {
const data = await this.doFetch(
`${this.getPlaybookRunsRoute()}${queryParams}`,
{method: 'get', groupLabel},
);
return data || {items: [], total_count: 0, page_count: 0, has_more: false};
} catch (error) {
return {items: [], total_count: 0, page_count: 0, has_more: false};
}
};
// fetchPlaybookRun = async (id: string, groupLabel?: RequestGroupLabel) => {
// return this.doFetch(
// `${this.getPlaybookRunRoute(id)}`,
// {method: 'get', groupLabel},
// );
// };
// Run Management
// finishRun = async (playbookRunId: string) => {
// try {
// return await this.doFetch(
// `${this.getPlaybookRunRoute(playbookRunId)}/finish`,
// {method: 'put'},
// );
// } catch (error) {
// return {error};
// }
// };
// Checklist Management
setChecklistItemState = async (playbookRunID: string, checklistNum: number, itemNum: number, newState: ChecklistItemState) => {
try {
return await this.doFetch(
`${this.getPlaybookRunRoute(playbookRunID)}/checklists/${checklistNum}/item/${itemNum}/state`,
{method: 'put', body: {new_state: newState}},
);
} catch (error) {
return {error};
}
};
// skipChecklistItem = async (playbookRunID: string, checklistNum: number, itemNum: number) => {
// await this.doFetch(
// `${this.getPlaybookRunRoute(playbookRunID)}/checklists/${checklistNum}/item/${itemNum}/skip`,
// {method: 'put', body: ''},
// );
// };
// skipChecklist = async (playbookRunID: string, checklistNum: number) => {
// await this.doFetch(
// `${this.getPlaybookRunRoute(playbookRunID)}/checklists/${checklistNum}/skip`,
// {method: 'put', body: ''},
// );
// };
// Slash Commands
runChecklistItemSlashCommand = async (playbookRunId: string, checklistNumber: number, itemNumber: number) => {
await this.doFetch(
`${this.getPlaybookRunRoute(playbookRunId)}/checklists/${checklistNumber}/item/${itemNumber}/run`,
{method: 'post'},
);
};
};
export default ClientPlaybooks;

View file

@ -0,0 +1,78 @@
// 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 PlaybookRunsOptionComponent from './playbook_runs_option';
import PlaybookRunsOption from './';
import type ServerDataOperator from '@database/operator/server_data_operator';
import type {Database} from '@nozbe/watermelondb';
jest.mock('./playbook_runs_option');
jest.mocked(PlaybookRunsOptionComponent).mockImplementation(
(props) => React.createElement('PlaybookRunsOption', {testID: 'playbook-runs-option', ...props}),
);
const serverUrl = 'server-url';
describe('PlaybookRunsOption', () => {
const channelId = 'channel-id';
function getBaseProps(): ComponentProps<typeof PlaybookRunsOption> {
return {
channelId,
};
}
let database: Database;
let operator: ServerDataOperator;
beforeEach(async () => {
await DatabaseManager.init([serverUrl]);
const serverDatabaseAndOperator = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
database = serverDatabaseAndOperator.database;
operator = serverDatabaseAndOperator.operator;
});
it('should render correctly without data', () => {
const props = getBaseProps();
const {getByTestId} = renderWithEverything(<PlaybookRunsOption {...props}/>, {database});
const playbookRunsOption = getByTestId('playbook-runs-option');
expect(playbookRunsOption).toBeTruthy();
expect(playbookRunsOption.props.channelId).toBe('channel-id');
expect(playbookRunsOption.props.playbooksActiveRuns).toBe(0);
expect(playbookRunsOption.props.channelName).toBe('');
});
it('should render correctly with data', async () => {
await operator.handleChannel({
prepareRecordsOnly: false,
channels: [TestHelper.fakeChannel({id: channelId, display_name: 'Some channel name'})],
});
await operator.handlePlaybookRun({
prepareRecordsOnly: false,
runs: [
TestHelper.fakePlaybookRun({id: 'run-1', channel_id: channelId}),
TestHelper.fakePlaybookRun({id: 'run-2', channel_id: channelId, end_at: 123}),
TestHelper.fakePlaybookRun({id: 'run-3', channel_id: channelId}),
],
});
const props = getBaseProps();
const {getByTestId} = renderWithEverything(<PlaybookRunsOption {...props}/>, {database});
const playbookRunsOption = getByTestId('playbook-runs-option');
expect(playbookRunsOption).toBeTruthy();
expect(playbookRunsOption.props.channelId).toBe('channel-id');
expect(playbookRunsOption.props.playbooksActiveRuns).toBe(2);
expect(playbookRunsOption.props.channelName).toBe('Some channel name');
});
});

View file

@ -0,0 +1,28 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {queryPlaybookRunsPerChannel} from '@playbooks/database/queries/run';
import {observeChannel} from '@queries/servers/channel';
import PlaybookRunsOption from './playbook_runs_option';
import type {WithDatabaseArgs} from '@typings/database/database';
type OwnProps = {
channelId: string;
} & WithDatabaseArgs;
const enhanced = withObservables(['channelId'], ({channelId, database}: OwnProps) => {
return {
playbooksActiveRuns: queryPlaybookRunsPerChannel(database, channelId, false).observeCount(false),
channelName: observeChannel(database, channelId).pipe(
switchMap((channel) => of$(channel?.displayName || '')),
),
};
});
export default withDatabase(enhanced(PlaybookRunsOption));

View file

@ -0,0 +1,58 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {Platform} from 'react-native';
import OptionItem from '@components/option_item';
import {goToPlaybookRuns} from '@playbooks/screens/navigation';
import {dismissBottomSheet} from '@screens/navigation';
import {renderWithIntl, waitFor} from '@test/intl-test-helper';
import PlaybookRunsOption from './playbook_runs_option';
jest.mock('@components/option_item');
jest.mocked(OptionItem).mockImplementation((props) => React.createElement('OptionItem', {testID: 'option-item', ...props}));
jest.mock('@playbooks/screens/navigation');
describe('PlaybookRunsOption', () => {
const baseProps = {
channelId: 'channel-id',
playbooksActiveRuns: 3,
channelName: 'channel-name',
};
it('renders correctly', () => {
const {getByTestId} = renderWithIntl(<PlaybookRunsOption {...baseProps}/>);
const optionItem = getByTestId('option-item');
expect(optionItem).toBeTruthy();
expect(optionItem.props.label).toBe('Playbook runs');
expect(optionItem.props.info).toBe('3');
expect(optionItem.props.icon).toBe('product-playbooks');
expect(optionItem.props.type).toBe('arrow');
expect(optionItem.props.action).toBeDefined();
});
it('uses correct type based on platform', () => {
// We need to use any because typescript is not inferring the correct type for the dict
jest.mocked(Platform.select).mockImplementation((dict) => dict.android ?? (dict as any).default);
const {getByTestId} = renderWithIntl(<PlaybookRunsOption {...baseProps}/>);
const optionItem = getByTestId('option-item');
expect(optionItem.props.type).toBe('default');
});
it('calls goToPlaybookRuns when pressed', async () => {
const {getByTestId} = renderWithIntl(<PlaybookRunsOption {...baseProps}/>);
const optionItem = getByTestId('option-item');
optionItem.props.action();
await waitFor(() => {
expect(dismissBottomSheet).toHaveBeenCalled();
expect(goToPlaybookRuns).toHaveBeenCalledWith(expect.anything(), 'channel-id', 'channel-name');
});
});
});

View file

@ -0,0 +1,41 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {Platform} from 'react-native';
import OptionItem from '@components/option_item';
import {goToPlaybookRuns} from '@playbooks/screens/navigation';
import {dismissBottomSheet} from '@screens/navigation';
type Props = {
channelId: string;
playbooksActiveRuns: number;
channelName: string;
}
const PlaybookRunsOption = ({
channelId,
playbooksActiveRuns,
channelName,
}: Props) => {
const intl = useIntl();
const onPress = useCallback(async () => {
await dismissBottomSheet();
goToPlaybookRuns(intl, channelId, channelName);
}, [intl, channelId, channelName]);
return (
<OptionItem
type={Platform.select({ios: 'arrow', default: 'default'})}
icon='product-playbooks'
action={onPress}
label={intl.formatMessage({id: 'playbooks.playbooks_runs.title', defaultMessage: 'Playbook runs'})}
info={playbooksActiveRuns.toString()}
/>
);
};
export default PlaybookRunsOption;

View file

@ -0,0 +1,4 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export {default} from './progress_bar';

View file

@ -0,0 +1,80 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {render} from '@testing-library/react-native';
import React, {type ComponentProps} from 'react';
import {Preferences} from '@constants';
import {useTheme} from '@context/theme';
import {changeOpacity} from '@utils/theme';
import ProgressBar from './progress_bar';
jest.mock('@context/theme', () => ({
useTheme: jest.fn(),
}));
jest.mocked(useTheme).mockReturnValue(Preferences.THEMES.denim);
describe('ProgressBar', () => {
function getBaseProps(): ComponentProps<typeof ProgressBar> {
return {
progress: 50,
isActive: true,
testID: 'progress-bar',
};
}
it('renders correctly with active state', () => {
const props = getBaseProps();
props.isActive = true;
props.progress = 50;
const {getByTestId} = render(<ProgressBar {...props}/>);
const progressBar = getByTestId('progress-bar');
expect(progressBar).toBeTruthy();
expect(progressBar).toHaveStyle({
width: '50%',
backgroundColor: Preferences.THEMES.denim.onlineIndicator,
});
});
it('renders correctly with inactive state', () => {
const props = getBaseProps();
props.isActive = false;
props.progress = 75;
const {getByTestId} = render(<ProgressBar {...props}/>);
const progressBar = getByTestId('progress-bar');
expect(progressBar).toBeTruthy();
expect(progressBar).toHaveStyle({
width: '75%',
backgroundColor: changeOpacity(Preferences.THEMES.denim.centerChannelColor, 0.4),
});
});
it('handles 0% progress', () => {
const props = getBaseProps();
props.progress = 0;
const {getByTestId} = render(<ProgressBar {...props}/>);
const progressBar = getByTestId('progress-bar');
expect(progressBar).toHaveStyle({
width: '0%',
});
});
it('handles 100% progress', () => {
const props = getBaseProps();
props.progress = 100;
const {getByTestId} = render(<ProgressBar {...props}/>);
const progressBar = getByTestId('progress-bar');
expect(progressBar).toHaveStyle({
width: '100%',
});
});
});

View file

@ -0,0 +1,46 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useMemo} from 'react';
import {View, type ViewStyle, type StyleProp} from 'react-native';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
const getStyleSheet = makeStyleSheetFromTheme((theme) => ({
progressBar: {
position: 'absolute',
bottom: 0,
left: 0,
height: 2,
backgroundColor: theme.onlineIndicator,
borderRadius: 2,
},
}));
type Props = {
progress: number;
isActive: boolean;
testID?: string;
}
function ProgressBar({progress, isActive, testID}: Props) {
const theme = useTheme();
const styles = getStyleSheet(theme);
const style = useMemo<StyleProp<ViewStyle>>(() => [
styles.progressBar,
{
width: `${progress}%`,
backgroundColor: isActive ? theme.onlineIndicator : changeOpacity(theme.centerChannelColor, 0.4),
},
], [styles.progressBar, progress, isActive, theme.onlineIndicator, theme.centerChannelColor]);
return (
<View
style={style}
testID={testID}
/>
);
}
export default ProgressBar;

View file

@ -0,0 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export const PLAYBOOK_TABLES = {
PLAYBOOK_RUN: 'PlaybookRun',
PLAYBOOK_CHECKLIST: 'PlaybookChecklist',
PLAYBOOK_CHECKLIST_ITEM: 'PlaybookChecklistItem',
};

View file

@ -0,0 +1,4 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export const PLAYBOOKS_PLUGIN_ID = 'playbooks';

View file

@ -0,0 +1,6 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export const MINIMUM_MAJOR_VERSION = 2;
export const MINIMUM_MINOR_VERSION = 2;
export const MINIMUM_PATCH_VERSION = 0;

View file

@ -0,0 +1,12 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {PLAYBOOKS_PLUGIN_ID} from '@playbooks/constants/plugin';
export const WEBSOCKET_EVENTS = {
WEBSOCKET_PLAYBOOK_RUN_UPDATED: `custom_${PLAYBOOKS_PLUGIN_ID}_playbook_run_updated`,
WEBSOCKET_PLAYBOOK_RUN_CREATED: `custom_${PLAYBOOKS_PLUGIN_ID}_playbook_run_created`,
WEBSOCKET_PLAYBOOK_RUN_UPDATED_INCREMENTAL: `custom_${PLAYBOOKS_PLUGIN_ID}_playbook_run_updated_incremental`,
WEBSOCKET_PLAYBOOK_CHECKLIST_UPDATED: `custom_${PLAYBOOKS_PLUGIN_ID}_playbook_checklist_updated`,
WEBSOCKET_PLAYBOOK_CHECKLIST_ITEM_UPDATED: `custom_${PLAYBOOKS_PLUGIN_ID}_playbook_checklist_item_updated`,
};

View file

@ -0,0 +1,6 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export {default as PlaybookRunModel} from './playbook_run';
export {default as PlaybookChecklistModel} from './playbook_checklist';
export {default as PlaybookChecklistItemModel} from './playbook_checklist_item';

View file

@ -0,0 +1,57 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {children, field, immutableRelation, json} from '@nozbe/watermelondb/decorators';
import Model, {type Associations} from '@nozbe/watermelondb/Model';
import {PLAYBOOK_TABLES} from '@playbooks/constants/database';
import {safeParseJSONStringArray} from '@utils/helpers';
import type {Query, Relation} from '@nozbe/watermelondb';
import type PlaybookChecklistModelInterface from '@playbooks/types/database/models/playbook_checklist';
import type PlaybookChecklistItemModel from '@playbooks/types/database/models/playbook_checklist_item';
import type PlaybookRunModel from '@playbooks/types/database/models/playbook_run';
import type {SyncStatus} from '@typings/database/database';
const {PLAYBOOK_RUN, PLAYBOOK_CHECKLIST, PLAYBOOK_CHECKLIST_ITEM} = PLAYBOOK_TABLES;
/**
* The PlaybookChecklist model represents a checklist in a playbook run in the Mattermost app.
*/
export default class PlaybookChecklistModel extends Model implements PlaybookChecklistModelInterface {
/** table (name) : PlaybookChecklist */
static table = PLAYBOOK_CHECKLIST;
/** associations : Describes every relationship to this table. */
static associations: Associations = {
/** A PLAYBOOK_CHECKLIST belongs to a PLAYBOOK_RUN (relationship is N:1) */
[PLAYBOOK_RUN]: {type: 'belongs_to', key: 'run_id'},
[PLAYBOOK_CHECKLIST_ITEM]: {type: 'has_many', foreignKey: 'checklist_id'},
};
/** run_id: The id of the playbook run this checklist belongs to */
@field('run_id') runId!: string;
/** title : Title of the checklist */
@field('title') title!: string;
/** sync : The sync status of the checklist */
@field('sync') sync!: SyncStatus;
/** last_sync_at : The timestamp when the checklist was last synced */
@field('last_sync_at') lastSyncAt!: number;
/** items_order : The sort order of the checklist */
@json('items_order', safeParseJSONStringArray) itemsOrder!: string[];
/** update_at : The timestamp when the checklist was updated */
@field('update_at') updateAt!: number;
/** items : All the items associated with this checklist */
@children(PLAYBOOK_CHECKLIST_ITEM) items!: Query<PlaybookChecklistItemModel>;
/** run : The playbook run to which this checklist belongs */
@immutableRelation(PLAYBOOK_RUN, 'run_id') run!: Relation<PlaybookRunModel>;
}

View file

@ -0,0 +1,87 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {field, immutableRelation, json} from '@nozbe/watermelondb/decorators';
import Model, {type Associations} from '@nozbe/watermelondb/Model';
import {MM_TABLES} from '@constants/database';
import {PLAYBOOK_TABLES} from '@playbooks/constants/database';
import {safeParseJSONStringArray} from '@utils/helpers';
import type {Relation} from '@nozbe/watermelondb';
import type PlaybookChecklistModel from '@playbooks/types/database/models/playbook_checklist';
import type PlaybookChecklistItemModelInterface from '@playbooks/types/database/models/playbook_checklist_item';
import type {SyncStatus} from '@typings/database/database';
import type UserModel from '@typings/database/models/servers/user';
const {USER} = MM_TABLES.SERVER;
const {PLAYBOOK_CHECKLIST_ITEM, PLAYBOOK_CHECKLIST} = PLAYBOOK_TABLES;
/**
* The PlaybookChecklistItem model represents an item in a checklist in a playbook run.
*/
export default class PlaybookChecklistItemModel extends Model implements PlaybookChecklistItemModelInterface {
/** table (name) : PlaybookChecklistItem */
static table = PLAYBOOK_CHECKLIST_ITEM;
/** associations : Describes every relationship to this table. */
static associations: Associations = {
/** A PLAYBOOK_CHECKLIST_ITEM belongs to a PLAYBOOK_CHECKLIST (relationship is N:1) */
[PLAYBOOK_CHECKLIST]: {type: 'belongs_to', key: 'checklist_id'},
/** A PLAYBOOK_CHECKLIST_ITEM belongs to a USER (relationship is N:1) */
[USER]: {type: 'belongs_to', key: 'assignee_id'},
};
/** checklist_id: The id of the checklist this checklist item belongs to */
@field('checklist_id') checklistId!: string;
/** title : Title of the checklist item */
@field('title') title!: string;
/** state : The state of the checklist item */
@field('state') state!: ChecklistItemState;
/** state_modified : The timestamp when the checklist item was modified */
@field('state_modified') stateModified!: number;
/** assignee_id: The id of the user who is assigned to the checklist item */
@field('assignee_id') assigneeId!: string | null;
/** assignee_modified : The timestamp when the assignee was modified */
@field('assignee_modified') assigneeModified!: number;
/** command : The slash command associated with the checklist item */
@field('command') command!: string | null;
/** command_last_run : The timestamp when the command was last run */
@field('command_last_run') commandLastRun!: number;
/** description : The description of the checklist item */
@field('description') description!: string;
/** due_date : The due date of the checklist item */
@field('due_date') dueDate!: number;
/** completed_at : The timestamp when the checklist item was completed */
@field('completed_at') completedAt!: number;
/** task_actions : The JSON string representing the task actions */
@json('task_actions', safeParseJSONStringArray) taskActions!: TaskAction[];
/** sync : The sync status of the checklist item */
@field('sync') sync!: SyncStatus;
/** last_sync_at : The timestamp when the checklist item was last synced */
@field('last_sync_at') lastSyncAt!: number;
/** update_at : The timestamp when the checklist item was updated */
@field('update_at') updateAt!: number;
/** checklist : The checklist to which this checklist item belongs */
@immutableRelation(PLAYBOOK_CHECKLIST, 'checklist_id') checklist!: Relation<PlaybookChecklistModel>;
/** user : The user to whom this checklist item is assigned */
@immutableRelation(USER, 'assignee_id') assignee?: Relation<UserModel>;
}

View file

@ -0,0 +1,144 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Q, type Query, type Relation} from '@nozbe/watermelondb';
import {children, field, immutableRelation, json} from '@nozbe/watermelondb/decorators';
import Model, {type Associations} from '@nozbe/watermelondb/Model';
import {MM_TABLES} from '@constants/database';
import {PLAYBOOK_TABLES} from '@playbooks/constants/database';
import {safeParseJSONStringArray} from '@utils/helpers';
import type PlaybookRunModelInterface from '@playbooks//types/database/models/playbook_run';
import type PlaybookChecklistModel from '@playbooks/types/database/models/playbook_checklist';
import type {SyncStatus} from '@typings/database/database';
import type ChannelModel from '@typings/database/models/servers/channel';
import type PostModel from '@typings/database/models/servers/post';
import type TeamModel from '@typings/database/models/servers/team';
import type UserModel from '@typings/database/models/servers/user';
const {
CHANNEL,
POST,
TEAM,
USER,
} = MM_TABLES.SERVER;
const {PLAYBOOK_RUN, PLAYBOOK_CHECKLIST} = PLAYBOOK_TABLES;
/**
* The PlaybookRun model represents a playbook run in the Mattermost app.
*/
export default class PlaybookRunModel extends Model implements PlaybookRunModelInterface {
/** table (name) : PlaybookRun */
static table = PLAYBOOK_RUN;
/** associations : Describes every relationship to this table. */
static associations: Associations = {
/** A PLAYBOOK_RUN could have been created by a POST (relationship is 1:1) */
[POST]: {type: 'belongs_to', key: 'post_id'},
/** A CHANNEL can be associated to one PLAYBOOK_RUN (relationship is 1:1) */
[CHANNEL]: {type: 'belongs_to', key: 'channel_id'},
/** A TEAM can be associated to PLAYBOOK_RUN (relationship is 1:1) */
[TEAM]: {type: 'belongs_to', key: 'team_id'},
/** A USER that commands the PLAYBOOK_RUN (relationship is 1:1) */
[USER]: {type: 'belongs_to', key: 'owner_user_id'},
[PLAYBOOK_CHECKLIST]: {type: 'has_many', foreignKey: 'run_id'},
};
/** playbook_id : The id of the playbook this run belongs to */
@field('playbook_id') playbookId!: string;
/** name : Name of the playbook run */
@field('name') name!: string;
/** description : Description of the playbook run */
@field('description') description!: string;
/** is_active : Whether the run is still active */
@field('is_active') isActive!: boolean;
/** owner_user_id : Foreign key to the user commanding the run */
@field('owner_user_id') ownerUserId!: string;
/** team_id : Foreign key to the team this run belongs to */
@field('team_id') teamId!: string;
/** channel_id : Foreign key to the channel this run belongs to */
@field('channel_id') channelId!: string;
/** post_id : ID of the post that created the run (nullable) */
@field('post_id') postId!: string | null;
/** create_at : Timestamp when the run was created */
@field('create_at') createAt!: number;
/** end_at: Timestamp when the run ended (0 if not finished) */
@field('end_at') endAt!: number;
/** active_stage : Zero-based index of the currently active stage */
@field('active_stage') activeStage!: number;
/** active_stage_title : Name of the current active stage */
@field('active_stage_title') activeStageTitle!: string;
/** participant_ids : An array of user IDs that participate in the run */
@json('participant_ids', safeParseJSONStringArray) participantIds!: string[];
/** summary : Summary of the playbook run */
@field('summary') summary!: string;
/** current_status : The current status of the playbook run */
@field('current_status') currentStatus!: PlaybookRunStatusType;
/** last_status_update_at : Timestamp of the last status update */
@field('last_status_update_at') lastStatusUpdateAt!: number;
/** retrospective_enabled : Indicates if retrospective is enabled for the run */
@field('retrospective_enabled') retrospectiveEnabled!: boolean;
/** retrospective : The retrospective details for the run */
@field('retrospective') retrospective!: string;
/** retrospective_published_at : Timestamp when the retrospective was published */
@field('retrospective_published_at') retrospectivePublishedAt!: number;
/** sync : The sync status of the playbook run */
@field('sync') sync!: SyncStatus;
/** last_sync_at : The timestamp when the playbook run was last synced */
@field('last_sync_at') lastSyncAt!: number;
/** previous_reminder : Timestamp of the previous reminder */
@field('previous_reminder') previousReminder!: number;
/** items_order : The sort order of the playbook run */
@json('items_order', safeParseJSONStringArray) itemsOrder!: string[];
/** update_at : The timestamp when the playbook run was updated */
@field('update_at') updateAt!: number;
/** post : The POST to which this PLAYBOOK_RUN belongs (can be null) */
@immutableRelation(POST, 'post_id') post!: Relation<PostModel>;
/** team : The TEAM to which the run CHANNEL belongs */
@immutableRelation(TEAM, 'team_id') team!: Relation<TeamModel>;
/** channel : The CHANNEL to which this PLAYBOOK_RUN belongs */
@immutableRelation(CHANNEL, 'channel_id') channel!: Relation<ChannelModel>;
/** creator : The USER who created this CHANNEL*/
@immutableRelation(USER, 'owner_user_id') owner!: Relation<UserModel>;
@children(PLAYBOOK_CHECKLIST) checklists!: Query<PlaybookChecklistModel>;
participants = (): Query<UserModel> => {
const filteredParticipantIds = this.participantIds.filter((id) => id !== this.ownerUserId);
return this.database.get<UserModel>(USER).query(Q.where('id', Q.oneOf(filteredParticipantIds)));
};
}

View file

@ -0,0 +1,78 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import TestHelper from '@test/test_helper';
import {shouldUpdatePlaybookRunRecord, shouldHandlePlaybookChecklistRecord, shouldHandlePlaybookChecklistItemRecord} from './';
describe('shouldUpdatePlaybookRunRecord', () => {
it('should return false when update_at is the same', () => {
const existingRecord = TestHelper.fakePlaybookRunModel({
updateAt: 112233,
});
const raw = TestHelper.fakePlaybookRun({
update_at: 112233,
});
expect(shouldUpdatePlaybookRunRecord(existingRecord, raw)).toBe(false);
});
it('should return true when update_at is different', () => {
const existingRecord = TestHelper.fakePlaybookRunModel({
updateAt: 112233,
});
const raw = TestHelper.fakePlaybookRun({
update_at: 112234,
});
expect(shouldUpdatePlaybookRunRecord(existingRecord, raw)).toBe(true);
});
});
describe('shouldHandlePlaybookChecklistRecord', () => {
it('should return false when update_at is the same', () => {
const existingRecord = TestHelper.fakePlaybookChecklistModel({
updateAt: 112233,
});
const raw = TestHelper.fakePlaybookChecklist(existingRecord.runId, {
update_at: 112233,
});
expect(shouldHandlePlaybookChecklistRecord(existingRecord, raw)).toBe(false);
});
it('should return true when update_at is different', () => {
const existingRecord = TestHelper.fakePlaybookChecklistModel({
updateAt: 112233,
});
const raw = TestHelper.fakePlaybookChecklist(existingRecord.runId, {
update_at: 112234,
});
expect(shouldHandlePlaybookChecklistRecord(existingRecord, raw)).toBe(true);
});
});
describe('shouldHandlePlaybookChecklistItemRecord', () => {
it('should return false when update_at is the same', () => {
const existingRecord = TestHelper.fakePlaybookChecklistItemModel({
updateAt: 112233,
});
const raw = TestHelper.fakePlaybookChecklistItem(existingRecord.checklistId, {
update_at: 112233,
});
expect(shouldHandlePlaybookChecklistItemRecord(existingRecord, raw)).toBe(false);
});
it('should return true when update_at is different', () => {
const existingRecord = TestHelper.fakePlaybookChecklistItemModel({
updateAt: 112233,
});
const raw = TestHelper.fakePlaybookChecklistItem(existingRecord.checklistId, {
update_at: 112234,
});
expect(shouldHandlePlaybookChecklistItemRecord(existingRecord, raw)).toBe(true);
});
});

View file

@ -0,0 +1,18 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type PlaybookChecklistModel from '@playbooks/types/database/models/playbook_checklist';
import type PlaybookChecklistItemModel from '@playbooks/types/database/models/playbook_checklist_item';
import type PlaybookRunModel from '@playbooks/types/database/models/playbook_run';
export const shouldUpdatePlaybookRunRecord = (existingRecord: PlaybookRunModel, raw: PartialPlaybookRun): boolean => {
return Boolean(existingRecord.updateAt !== raw.update_at);
};
export const shouldHandlePlaybookChecklistRecord = (existingRecord: PlaybookChecklistModel, raw: PartialChecklist): boolean => {
return Boolean(existingRecord.updateAt !== raw.update_at);
};
export const shouldHandlePlaybookChecklistItemRecord = (existingRecord: PlaybookChecklistItemModel, raw: PartialChecklistItem): boolean => {
return Boolean(existingRecord.updateAt !== raw.update_at);
};

View file

@ -0,0 +1,556 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import DatabaseManager from '@database/manager';
import * as dbUtils from '@database/operator/utils/general';
import {PLAYBOOK_TABLES} from '@playbooks/constants/database';
import TestHelper from '@test/test_helper';
import type ServerDataOperator from '@database/operator/server_data_operator';
const {PLAYBOOK_RUN, PLAYBOOK_CHECKLIST, PLAYBOOK_CHECKLIST_ITEM} = PLAYBOOK_TABLES;
describe('PlaybookHandler', () => {
let operator: ServerDataOperator;
beforeEach(async () => {
await DatabaseManager.init(['playbookHandler.test.com']);
operator = DatabaseManager.serverDatabases['playbookHandler.test.com']!.operator;
});
afterEach(async () => {
await DatabaseManager.destroyServerDatabase('playbookHandler.test.com');
});
describe('handlePlaybookRun', () => {
it('should return an empty array if runs is undefined or empty', async () => {
const spyOnPrepareRecords = jest.spyOn(operator, 'prepareRecords');
let result = await operator.handlePlaybookRun({
runs: undefined,
prepareRecordsOnly: true,
removeAssociatedRecords: false,
});
expect(result).toEqual([]);
expect(spyOnPrepareRecords).not.toHaveBeenCalled();
result = await operator.handlePlaybookRun({
runs: [],
prepareRecordsOnly: true,
removeAssociatedRecords: false,
});
expect(result).toEqual([]);
expect(spyOnPrepareRecords).not.toHaveBeenCalled();
});
it('should process runs correctly', async () => {
const mockRuns = TestHelper.createPlaybookRuns(2, 2, 3);
const spyOnPrepareRecords = jest.spyOn(operator, 'prepareRecords');
const spyOnBatchOperation = jest.spyOn(operator, 'batchRecords');
const result = await operator.handlePlaybookRun({
runs: mockRuns,
prepareRecordsOnly: false,
removeAssociatedRecords: false,
});
expect(result).toBeDefined();
expect(result.length).toBe(mockRuns.length);
expect(spyOnPrepareRecords).toHaveBeenCalledTimes(1);
expect(spyOnBatchOperation).toHaveBeenCalledTimes(1);
});
it('should NOT delete runs newly marked as ended', async () => {
const mockRuns = TestHelper.createPlaybookRuns(2, 2, 3);
await operator.handlePlaybookRun({
runs: mockRuns,
prepareRecordsOnly: false,
removeAssociatedRecords: false, // Remove associated records
keepFinishedRuns: false,
});
// Mark the runs for deletion by setting `end_at` to a non-zero value
mockRuns.forEach((run) => {
run.end_at = 1620000005000;
run.update_at = run.update_at + 1;
});
jest.clearAllMocks();
const spyOnPrepareDestroy = jest.spyOn(dbUtils, 'prepareDestroyPermanentlyChildrenAssociatedRecords');
const spyOnBatchOperation = jest.spyOn(operator, 'batchRecords');
const result = await operator.handlePlaybookRun({
runs: mockRuns,
prepareRecordsOnly: false,
removeAssociatedRecords: false, // No associated records to remove
keepFinishedRuns: false,
});
expect(result).toBeDefined();
expect(result.length).toBe(mockRuns.length); // All runs should be processed
expect(spyOnPrepareDestroy).not.toHaveBeenCalled(); // No associated records should be removed
expect(spyOnBatchOperation).toHaveBeenCalledTimes(1); // Batch operation should be called once
const {database} = operator;
// Verify that the playbook_run table is empty
const playbookRunRecords = await database.get(PLAYBOOK_RUN).query().fetch();
expect(playbookRunRecords.length).toBe(2);
// Verify that the checklist table is empty
const checklistRecords = await database.get(PLAYBOOK_CHECKLIST).query().fetch();
expect(checklistRecords.length).toBe(0);
// Verify that the checklist_item table is empty
const checklistItemRecords = await database.get(PLAYBOOK_CHECKLIST_ITEM).query().fetch();
expect(checklistItemRecords.length).toBe(0);
});
it('should delete runs previously ended and keep the last 5', async () => {
const mockRuns = TestHelper.createPlaybookRuns(8, 2, 3);
// Mark the runs for deletion by setting `end_at` to a non-zero value
mockRuns.forEach((run) => {
run.end_at = 1620000005000;
});
await operator.handlePlaybookRun({
runs: mockRuns,
prepareRecordsOnly: false,
removeAssociatedRecords: false, // Remove associated records
keepFinishedRuns: false,
});
jest.clearAllMocks();
const spyOnPrepareDestroy = jest.spyOn(dbUtils, 'prepareDestroyPermanentlyChildrenAssociatedRecords');
const spyOnBatchOperation = jest.spyOn(operator, 'batchRecords');
const result = await operator.handlePlaybookRun({
runs: mockRuns,
prepareRecordsOnly: false,
removeAssociatedRecords: false, // No associated records to remove
keepFinishedRuns: false,
});
expect(result).toBeDefined();
expect(result.length).toBe(3); // Returns the 3 deleted runs
expect(spyOnPrepareDestroy).not.toHaveBeenCalled(); // No associated records should be removed
expect(spyOnBatchOperation).toHaveBeenCalledTimes(1); // Batch operation should be called once
const {database} = operator;
// Verify that the playbook_run table is empty
const playbookRunRecords = await database.get(PLAYBOOK_RUN).query().fetch();
expect(playbookRunRecords.length).toBe(5);
// Verify that the checklist table is empty
const checklistRecords = await database.get(PLAYBOOK_CHECKLIST).query().fetch();
expect(checklistRecords.length).toBe(0);
// Verify that the checklist_item table is empty
const checklistItemRecords = await database.get(PLAYBOOK_CHECKLIST_ITEM).query().fetch();
expect(checklistItemRecords.length).toBe(0);
});
it('should create runs and process children', async () => {
const mockRuns = TestHelper.createPlaybookRuns(2, 2, 3);
// Calculate the expected total number of processed records
const totalRuns = mockRuns.length;
const allChecklists = mockRuns.reduce<PlaybookChecklist[]>((checklists, run) => {
if (run.checklists.length) {
checklists.push(...run.checklists);
}
return checklists;
}, []);
const totalChecklists = allChecklists.length;
const allChecklistItems = allChecklists.reduce<PlaybookChecklistItem[]>((items, checklist) => {
if (checklist.items.length) {
items.push(...checklist.items);
}
return items;
}, []);
const totalChecklistItems = allChecklistItems.length;
const expectedTotalRecords = totalChecklistItems + totalChecklists + totalRuns;
const spyOnPrepareRecords = jest.spyOn(operator, 'prepareRecords');
const spyOnBatchOperation = jest.spyOn(operator, 'batchRecords');
const spyOnHandleChecklist = jest.spyOn(operator, 'handlePlaybookChecklist');
const result = await operator.handlePlaybookRun({
runs: mockRuns,
prepareRecordsOnly: false,
processChildren: true, // Process associated checklists
});
// Assertions
expect(result).toBeDefined();
expect(result.length).toBe(expectedTotalRecords); // All runs should be processed
expect(spyOnPrepareRecords).toHaveBeenCalledTimes(3); // Runs should be prepared
expect(spyOnBatchOperation).toHaveBeenCalledTimes(1); // Batch operation should be called once
expect(spyOnHandleChecklist).toHaveBeenCalledTimes(1); // Checklists should be processed
const {database} = operator;
// Verify that the playbook_run table contains the created runs
const playbookRunRecords = await database.get(PLAYBOOK_RUN).query().fetch();
expect(playbookRunRecords.length).toBe(mockRuns.length);
// Verify that the checklist table contains the created checklists
const checklistRecords = await database.get(PLAYBOOK_CHECKLIST).query().fetch();
expect(checklistRecords.length).toBe(totalChecklists);
// Verify that the checklist_item table contains the created items
const checklistItemRecords = await database.get(PLAYBOOK_CHECKLIST_ITEM).query().fetch();
expect(checklistItemRecords.length).toBe(totalChecklistItems);
});
it('should delete runs with associated records', async () => {
const mockRuns = TestHelper.createPlaybookRuns(8, 2, 3);
// Mark the runs for deletion by setting `end_at` to a non-zero value
mockRuns.forEach((run, index) => {
run.end_at = 1620000005000 + index;
});
await operator.handlePlaybookRun({
runs: mockRuns,
prepareRecordsOnly: false,
removeAssociatedRecords: false,
processChildren: true,
});
// Calculate the expected total number of processed records
const runsThatWillBeDeleted = mockRuns.sort((a, b) => b.end_at - a.end_at).slice(-3);
const totalRuns = runsThatWillBeDeleted.length; // 3 runs will be deleted
const allChecklists = runsThatWillBeDeleted.reduce<PlaybookChecklist[]>((checklists, run) => {
if (run.checklists.length) {
checklists.push(...run.checklists);
}
return checklists;
}, []);
const totalChecklists = allChecklists.length;
const allChecklistItems = allChecklists.reduce<PlaybookChecklistItem[]>((items, checklist) => {
if (checklist.items.length) {
items.push(...checklist.items);
}
return items;
}, []);
const totalChecklistItems = allChecklistItems.length;
const expectedTotalRecords = totalChecklistItems + totalChecklists + totalRuns;
jest.clearAllMocks();
const spyOnPrepareDestroy = jest.spyOn(dbUtils, 'prepareDestroyPermanentlyChildrenAssociatedRecords');
const spyOnBatchOperation = jest.spyOn(operator, 'batchRecords');
const result = await operator.handlePlaybookRun({
runs: mockRuns,
prepareRecordsOnly: false,
removeAssociatedRecords: true, // Remove associated records
keepFinishedRuns: false,
});
// Assertions
expect(result).toBeDefined();
expect(result.length).toBe(expectedTotalRecords); // All runs should be processed
expect(spyOnPrepareDestroy).toHaveBeenCalledTimes(1); // Associated records should be removed
expect(spyOnBatchOperation).toHaveBeenCalledTimes(1); // Batch operation should be called once
const {database} = operator;
// Verify that the playbook_run table has 5 finished runs
const finishedNotDeleted = mockRuns.sort((a, b) => b.end_at - a.end_at).slice(0, 5);
const playbookRunRecords = await database.get(PLAYBOOK_RUN).query().fetch();
expect(playbookRunRecords.length).toBe(finishedNotDeleted.length);
// Verify that the checklist only contains the checklists of the remaining runs
const checklistRecords = await database.get(PLAYBOOK_CHECKLIST).query().fetch();
const remainingChecklists = finishedNotDeleted.reduce<PlaybookChecklist[]>((checklists, run) => {
if (run.checklists.length) {
checklists.push(...run.checklists);
}
return checklists;
}, []);
expect(checklistRecords.length).toBe(remainingChecklists.length);
// Verify that the checklist_item only contains items of the remaining runs
const checklistItemRecords = await database.get(PLAYBOOK_CHECKLIST_ITEM).query().fetch();
const remainingChecklistItems = remainingChecklists.reduce<PlaybookChecklistItem[]>((items, checklist) => {
if (checklist.items.length) {
items.push(...checklist.items);
}
return items;
}, []);
expect(checklistItemRecords.length).toBe(remainingChecklistItems.length);
});
it('should Keep all finished runs', async () => {
const mockRuns = TestHelper.createPlaybookRuns(8, 2, 3);
// Mark the runs for deletion by setting `end_at` to a non-zero value
mockRuns.forEach((run, index) => {
run.end_at = 1620000005000 + index;
});
await operator.handlePlaybookRun({
runs: mockRuns,
prepareRecordsOnly: false,
removeAssociatedRecords: false,
processChildren: true,
keepFinishedRuns: false,
});
// Calculate the expected total number of processed records
const allChecklists = mockRuns.reduce<PlaybookChecklist[]>((checklists, run) => {
if (run.checklists.length) {
checklists.push(...run.checklists);
}
return checklists;
}, []);
const allChecklistItems = allChecklists.reduce<PlaybookChecklistItem[]>((items, checklist) => {
if (checklist.items.length) {
items.push(...checklist.items);
}
return items;
}, []);
jest.clearAllMocks();
const spyOnPrepareDestroy = jest.spyOn(dbUtils, 'prepareDestroyPermanentlyChildrenAssociatedRecords');
const spyOnBatchOperation = jest.spyOn(operator, 'batchRecords');
const result = await operator.handlePlaybookRun({
runs: mockRuns,
prepareRecordsOnly: false,
removeAssociatedRecords: true, // Remove associated records
keepFinishedRuns: true,
});
// Assertions
expect(result).toBeDefined();
expect(result.length).toBe(0); // No runs should be processed
expect(spyOnPrepareDestroy).toHaveBeenCalledTimes(0); // Associated records should be removed
expect(spyOnBatchOperation).toHaveBeenCalledTimes(0); // Batch operation should be called once
const {database} = operator;
// Verify that the playbook_run table has all the finished runs
const playbookRunRecords = await database.get(PLAYBOOK_RUN).query().fetch();
expect(playbookRunRecords.length).toBe(mockRuns.length);
// Verify that the checklist only contains the checklists of the remaining runs
const checklistRecords = await database.get(PLAYBOOK_CHECKLIST).query().fetch();
expect(checklistRecords.length).toBe(allChecklists.length);
// Verify that the checklist_item only contains items of the remaining runs
const checklistItemRecords = await database.get(PLAYBOOK_CHECKLIST_ITEM).query().fetch();
expect(checklistItemRecords.length).toBe(allChecklistItems.length);
});
});
describe('handlePlaybookChecklist', () => {
it('should return an empty array if checklists is undefined or empty', async () => {
const spyOnPrepareRecords = jest.spyOn(operator, 'prepareRecords');
const result = await operator.handlePlaybookChecklist({
checklists: undefined,
prepareRecordsOnly: false,
});
expect(result).toEqual([]);
expect(spyOnPrepareRecords).not.toHaveBeenCalled();
});
it('should process checklists correctly', async () => {
const mockChecklists = [
TestHelper.createPlaybookChecklist('playbook_run_1', 3, 0),
TestHelper.createPlaybookChecklist('playbook_run_1', 2, 1),
].map((checklist, index) => ({
...checklist,
run_id: 'playbook_run_1',
order: index,
}));
const spyOnPrepareRecords = jest.spyOn(operator, 'prepareRecords');
const spyOnBatchOperation = jest.spyOn(operator, 'batchRecords');
const result = await operator.handlePlaybookChecklist({
checklists: mockChecklists,
prepareRecordsOnly: false,
});
expect(result).toBeDefined();
expect(result.length).toBe(mockChecklists.length);
expect(spyOnPrepareRecords).toHaveBeenCalledTimes(1);
expect(spyOnBatchOperation).toHaveBeenCalledTimes(1);
});
it('should create checklists and process children', async () => {
const mockChecklists = [
TestHelper.createPlaybookChecklist('playbook_run_1', 3, 0),
TestHelper.createPlaybookChecklist('playbook_run_1', 2, 1),
].map((checklist, index) => ({
...checklist,
run_id: 'playbook_run_1',
order: index,
}));
const spyOnPrepareRecords = jest.spyOn(operator, 'prepareRecords');
const spyOnBatchOperation = jest.spyOn(operator, 'batchRecords');
const spyOnHandleChecklistItem = jest.spyOn(operator, 'handlePlaybookChecklistItem');
const result = await operator.handlePlaybookChecklist({
checklists: mockChecklists,
prepareRecordsOnly: false,
processChildren: true, // Process associated checklist items
});
// Calculate the expected total number of processed records
const totalChecklists = mockChecklists.length;
const totalChecklistItems = mockChecklists.reduce((count, checklist) => count + checklist.items.length, 0);
const expectedTotalRecords = totalChecklists + totalChecklistItems;
// Assertions
expect(result).toBeDefined();
expect(result.length).toBe(expectedTotalRecords); // All checklists and items should be processed
expect(spyOnPrepareRecords).toHaveBeenCalledTimes(2); // Checklists should be prepared
expect(spyOnBatchOperation).toHaveBeenCalledTimes(1); // Batch operation should be called once
expect(spyOnHandleChecklistItem).toHaveBeenCalledTimes(1); // Checklist items should be processed
const {database} = operator;
// Verify that the checklist table contains the created checklists
const checklistRecords = await database.get(PLAYBOOK_CHECKLIST).query().fetch();
expect(checklistRecords.length).toBe(totalChecklists);
// Verify that the checklist_item table contains the created items
const checklistItemRecords = await database.get(PLAYBOOK_CHECKLIST_ITEM).query().fetch();
expect(checklistItemRecords.length).toBe(totalChecklistItems);
});
it('should handle checklists with a combination of flags', async () => {
const mockChecklists = [
TestHelper.createPlaybookChecklist('playbook_run_1', 3, 0),
TestHelper.createPlaybookChecklist('playbook_run_1', 2, 1),
].map((checklist, index) => ({
...checklist,
run_id: 'playbook_run_1',
order: index,
}));
await operator.handlePlaybookChecklist({
checklists: mockChecklists,
prepareRecordsOnly: false,
});
jest.clearAllMocks();
const spyOnPrepareRecords = jest.spyOn(operator, 'prepareRecords');
const spyOnBatchOperation = jest.spyOn(operator, 'batchRecords');
const spyOnHandleChecklistItem = jest.spyOn(operator, 'handlePlaybookChecklistItem');
const result = await operator.handlePlaybookChecklist({
checklists: mockChecklists,
prepareRecordsOnly: false, // Only prepare records, do not save to the database
processChildren: true, // Process associated checklist items
});
// Assertions
expect(result).toBeDefined();
// Verify that prepareRecords was called for the checklists
expect(spyOnPrepareRecords).toHaveBeenCalledTimes(1);
// Verify that handlePlaybookChecklistItem was called to process children
expect(spyOnHandleChecklistItem).toHaveBeenCalledTimes(1);
// Verify that batchRecords was not called since prepareRecordsOnly is true
expect(spyOnBatchOperation).toHaveBeenCalledTimes(1);
const {database} = operator;
// Verify that the checklist table still contains the original checklists since records were only prepared
const checklistRecords = await database.get(PLAYBOOK_CHECKLIST).query().fetch();
expect(checklistRecords.length).toBeGreaterThan(0); // No records should be saved
// Verify that the checklist_item table is empty
const checklistItemRecords = await database.get(PLAYBOOK_CHECKLIST_ITEM).query().fetch();
expect(checklistItemRecords.length).toBeGreaterThan(0);
});
});
describe('handlePlaybookChecklistItem', () => {
it('should return an empty array if items is undefined or empty', async () => {
const spyOnPrepareRecords = jest.spyOn(operator, 'prepareRecords');
const result = await operator.handlePlaybookChecklistItem({
items: undefined,
prepareRecordsOnly: true,
});
expect(result).toEqual([]);
expect(spyOnPrepareRecords).not.toHaveBeenCalled();
});
it('should process checklist items correctly', async () => {
const mockItems = [
TestHelper.createPlaybookItem('checklist_1', 0),
TestHelper.createPlaybookItem('checklist_1', 1),
].map<PartialChecklistItem>((item, index) => ({
...item,
checklist_id: 'checklist_1',
order: index,
}));
const spyOnPrepareRecords = jest.spyOn(operator, 'prepareRecords');
const spyOnBatchOperation = jest.spyOn(operator, 'batchRecords');
const result = await operator.handlePlaybookChecklistItem({
items: mockItems,
prepareRecordsOnly: false,
});
expect(result).toBeDefined();
expect(result.length).toBe(mockItems.length);
expect(spyOnPrepareRecords).toHaveBeenCalledTimes(1);
expect(spyOnBatchOperation).toHaveBeenCalledTimes(1);
});
it('should handle checklist items with prepareRecordsOnly flag', async () => {
const mockItems = [
TestHelper.createPlaybookItem('checklist_1', 0),
TestHelper.createPlaybookItem('checklist_1', 1),
].map<PartialChecklistItem>((item, index) => ({
...item,
checklist_id: 'checklist_1',
order: index,
}));
const spyOnPrepareRecords = jest.spyOn(operator, 'prepareRecords');
const spyOnBatchOperation = jest.spyOn(operator, 'batchRecords');
const result = await operator.handlePlaybookChecklistItem({
items: mockItems,
prepareRecordsOnly: true, // Only prepare records, do not save to the database
});
expect(result).toBeDefined();
expect(result.length).toBe(mockItems.length); // All items should be processed
expect(spyOnPrepareRecords).toHaveBeenCalledTimes(1);
expect(spyOnBatchOperation).not.toHaveBeenCalled(); // No batch operation should be performed
const {database} = operator;
// Verify that the checklist_item table is still empty
const checklistItemRecords = await database.get(PLAYBOOK_CHECKLIST_ITEM).query().fetch();
expect(checklistItemRecords.length).toBe(0);
});
});
});

View file

@ -0,0 +1,279 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Model, Q} from '@nozbe/watermelondb';
import {getUniqueRawsBy, prepareDestroyPermanentlyChildrenAssociatedRecords} from '@database/operator/utils/general';
import {PLAYBOOK_TABLES} from '@playbooks/constants/database';
import {logWarning} from '@utils/log';
import {shouldHandlePlaybookChecklistItemRecord, shouldHandlePlaybookChecklistRecord, shouldUpdatePlaybookRunRecord} from '../comparators';
import {transformPlaybookChecklistItemRecord, transformPlaybookChecklistRecord, transformPlaybookRunRecord} from '../transformers';
import type ServerDataOperatorBase from '@database/operator/server_data_operator/handlers';
import type PlaybookChecklistModel from '@playbooks/types/database/models/playbook_checklist';
import type PlaybookChecklistItemModel from '@playbooks/types/database/models/playbook_checklist_item';
import type PlaybookRunModel from '@playbooks/types/database/models/playbook_run';
type HandlePlaybookRunArgs = {
prepareRecordsOnly: boolean;
removeAssociatedRecords?: boolean;
processChildren?: boolean;
keepFinishedRuns?: boolean;
runs?: PartialPlaybookRun[];
}
type HandlePlaybookChecklistArgs = {
prepareRecordsOnly: boolean;
processChildren?: boolean;
checklists?: PartialChecklist[];
}
type HandlePlaybookChecklistItemArgs = {
prepareRecordsOnly: boolean;
items?: PartialChecklistItem[];
}
const {PLAYBOOK_RUN, PLAYBOOK_CHECKLIST, PLAYBOOK_CHECKLIST_ITEM} = PLAYBOOK_TABLES;
export interface PlaybookHandlerMix {
handlePlaybookRun: (args: HandlePlaybookRunArgs) => Promise<Model[]>;
handlePlaybookChecklist: (args: HandlePlaybookChecklistArgs) => Promise<Model[]>;
handlePlaybookChecklistItem: (args: HandlePlaybookChecklistItemArgs) => Promise<PlaybookChecklistItemModel[]>;
}
const PlaybookHandler = <TBase extends Constructor<ServerDataOperatorBase>>(superclass: TBase) => class extends superclass {
/**
* Handles the playbook run records.
* @param {HandlePlaybookRunArgs} args - The arguments for handling playbook run records.
* @param {boolean} args.prepareRecordsOnly - If true, only prepares the records without saving them.
* @param {boolean} [args.processChildren] - If true, process child records.
* @param {boolean} [args.removeAssociatedRecords] - If true, remove associated records.
* @param {PlaybookRun[]} [args.runs] - The playbook run records to handle.
* @returns {Promise<any[]>} - A promise that resolves to an array of handled playbook run records.
*/
handlePlaybookRun = async ({runs, keepFinishedRuns, prepareRecordsOnly, processChildren, removeAssociatedRecords}: HandlePlaybookRunArgs): Promise<Model[]> => {
if (!runs?.length) {
logWarning(
'An empty or undefined "runs" array has been passed to the handlePlaybookRun method',
);
return [];
}
const batchRecords: Model[] = [];
const uniqueRaws = getUniqueRawsBy({raws: runs, key: 'id'});
const keys = uniqueRaws.map((raw) => raw.id);
const existingRecords = await this.database.collections.get<PlaybookRunModel>(PLAYBOOK_RUN).query(
Q.where('id', Q.oneOf(keys)),
).fetch();
const existingRecordsMap = new Map(existingRecords.map((record) => [record.id, record]));
// get the raws to create or update, will handle deletion later
const createOrUpdateRaws = uniqueRaws.reduce<PartialPlaybookRun[]>((res, raw) => {
const existingRecord = existingRecordsMap.get(raw.id);
if (!existingRecord) {
res.push(raw);
} else if (shouldUpdatePlaybookRunRecord(existingRecord, raw)) {
res.push(raw);
}
return res;
}, []);
if (createOrUpdateRaws.length) {
const records = await this.handleRecords({
fieldName: 'id',
tableName: PLAYBOOK_RUN,
prepareRecordsOnly: true,
createOrUpdateRawValues: createOrUpdateRaws,
transformer: transformPlaybookRunRecord,
}, 'handlePlaybookRun prepare');
batchRecords.push(...records);
}
const toRemove: Model[] = [];
if (!keepFinishedRuns) {
const finishedRaws = new Set(createOrUpdateRaws.filter((raw) => raw.end_at).map((raw) => (raw.id)));
const numNewFinished = finishedRaws.size;
const clauses: Q.Clause[] = [
Q.where('end_at', Q.notEq(0)),
Q.sortBy('end_at', 'desc'),
];
if (numNewFinished < 5) {
clauses.push(Q.skip(5 - numNewFinished));
clauses.push(Q.take(9999)); // Required when using skip
}
const existingFinishedRecords = await this.database.collections.get<PlaybookRunModel>(PLAYBOOK_RUN).
query(...clauses).
fetch();
for (const record of existingFinishedRecords) {
if (!finishedRaws.has(record.id)) {
record.prepareDestroyPermanently();
batchRecords.push(record);
if (removeAssociatedRecords) {
toRemove.push(record);
}
}
}
if (toRemove.length) {
const childrenToRemove = await prepareDestroyPermanentlyChildrenAssociatedRecords(toRemove);
batchRecords.push(...childrenToRemove);
}
}
if (processChildren) {
const checklists = uniqueRaws.reduce<PartialChecklist[]>((res, raw) => {
if (raw.checklists?.length) {
let lists: PartialChecklist[] = raw.checklists.map((checklist, index) => ({
...checklist,
order: index,
run_id: raw.id,
}));
if (removeAssociatedRecords && toRemove.length) {
const deletedRunIds = new Set(toRemove.map((run) => run.id));
lists = lists.filter((item) => !deletedRunIds.has(item.run_id)); // Filter out items that are already marked for deletion
}
res.push(...lists);
}
return res;
}, []);
const childRecords = await this.handlePlaybookChecklist({checklists, prepareRecordsOnly: true, processChildren});
batchRecords.push(...childRecords);
}
if (batchRecords.length && !prepareRecordsOnly) {
await this.batchRecords(batchRecords, 'handlePlaybookRun batch');
}
return batchRecords;
};
/**
* Handles the playbook checklist records.
* @param {HandlePlaybookChecklistArgs} args - The arguments for handling playbook checklist records.
* @param {boolean} args.prepareRecordsOnly - If true, only prepares the records without saving them.
* @param {boolean} [args.processChildren] - If true, process child records.
* @param {PlaybookChecklistWithRun[]} [args.checklists] - The playbook checklist records to handle.
* @returns {Promise<Model[]>} - A promise that resolves to an array of handled playbook checklist records.
*/
handlePlaybookChecklist = async ({checklists, prepareRecordsOnly, processChildren = false}: HandlePlaybookChecklistArgs): Promise<Model[]> => {
if (!checklists?.length) {
logWarning(
'An empty or undefined "checklists" array has been passed to the handlePlaybookChecklist method',
);
return [];
}
const batchRecords: Model[] = [];
const uniqueRaws = getUniqueRawsBy({raws: checklists, key: 'id'});
const keys = uniqueRaws.map((raw) => raw.id);
const existingRecords = await this.database.collections.get<PlaybookChecklistModel>(PLAYBOOK_CHECKLIST).query(
Q.where('id', Q.oneOf(keys)),
).fetch();
const existingRecordsMap = new Map(existingRecords.map((record) => [record.id, record]));
const createOrUpdateRaws = uniqueRaws.reduce<PartialChecklist[]>((res, raw) => {
const existingRecord = existingRecordsMap.get(raw.id);
if (!existingRecord) {
res.push(raw);
} else if (shouldHandlePlaybookChecklistRecord(existingRecord, raw)) {
res.push(raw);
}
return res;
}, []);
if (createOrUpdateRaws.length) {
const records = await this.handleRecords({
fieldName: 'id',
tableName: PLAYBOOK_CHECKLIST,
prepareRecordsOnly: true,
createOrUpdateRawValues: createOrUpdateRaws,
transformer: transformPlaybookChecklistRecord,
}, 'handlePlaybookChecklist prepare');
batchRecords.push(...records);
}
if (processChildren) {
const items = uniqueRaws.reduce<PartialChecklistItem[]>((res, raw) => {
if (raw.items?.length) {
const lists: PartialChecklistItem[] = raw.items.
map((item, index) => ({
...item,
checklist_id: raw.id,
order: index,
}));
res.push(...lists);
}
return res;
}, []);
const childRecords = await this.handlePlaybookChecklistItem({items, prepareRecordsOnly: true});
batchRecords.push(...childRecords);
}
if (batchRecords.length && !prepareRecordsOnly) {
await this.batchRecords(batchRecords, 'handlePlaybookChecklist batch');
}
return batchRecords;
};
/**
* Handles the playbook checklist item records.
* @param {HandlePlaybookChecklistArgs} args - The arguments for handling playbook checklist item records.
* @param {boolean} args.prepareRecordsOnly - If true, only prepares the records without saving them.
* @param {PlaybookChecklistItemWithChecklist[]} [args.items] - The playbook checklist item records to handle.
* @returns {Promise<Model[]>} - A promise that resolves to an array of handled playbook checklist item records.
*/
handlePlaybookChecklistItem = async ({items, prepareRecordsOnly}: HandlePlaybookChecklistItemArgs): Promise<PlaybookChecklistItemModel[]> => {
if (!items?.length) {
logWarning(
'An empty or undefined "items" array has been passed to the handlePlaybookChecklistItem method',
);
return [];
}
const uniqueRaws = getUniqueRawsBy({raws: items, key: 'id'});
const keys = uniqueRaws.map((raw) => raw.id);
const existingRecords = await this.database.collections.get<PlaybookChecklistItemModel>(PLAYBOOK_CHECKLIST_ITEM).query(
Q.where('id', Q.oneOf(keys)),
).fetch();
const existingRecordsMap = new Map(existingRecords.map((record) => [record.id, record]));
const createOrUpdateRaws = uniqueRaws.reduce<PartialChecklistItem[]>((res, raw) => {
const existingRecord = existingRecordsMap.get(raw.id);
if (!existingRecord) {
res.push(raw);
} else if (shouldHandlePlaybookChecklistItemRecord(existingRecord, raw)) {
res.push(raw);
}
return res;
}, []);
if (!createOrUpdateRaws.length) {
return [];
}
const records = await this.handleRecords({
fieldName: 'id',
tableName: PLAYBOOK_CHECKLIST_ITEM,
prepareRecordsOnly: true,
createOrUpdateRawValues: createOrUpdateRaws,
transformer: transformPlaybookChecklistItemRecord,
}, 'handlePlaybookChecklistItem prepare');
if (records.length && !prepareRecordsOnly) {
await this.batchRecords(records, 'handlePlaybookChecklistItem batch');
}
return records;
};
};
export default PlaybookHandler;

View file

@ -0,0 +1,644 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/* eslint-disable max-lines */
import {OperationType} from '@constants/database';
import {createTestConnection} from '@database/operator/utils/create_test_connection';
import {PLAYBOOK_TABLES} from '@playbooks/constants/database';
import {PlaybookRunModel} from '@playbooks/database/models';
import {transformPlaybookChecklistItemRecord, transformPlaybookChecklistRecord, transformPlaybookRunRecord} from '.';
import type PlaybookChecklistModel from '@playbooks/types/database/models/playbook_checklist';
import type PlaybookChecklistItemModel from '@playbooks/types/database/models/playbook_checklist_item';
const {PLAYBOOK_RUN} = PLAYBOOK_TABLES;
describe('*** PLAYBOOK_RUN Prepare Records Test ***', () => {
it('=> transformPlaybookRunRecord: should return a record of type PlaybookRun for CREATE action', async () => {
expect.assertions(3);
const database = await createTestConnection({databaseName: 'playbook_run_prepare_records', setActive: true});
expect(database).toBeTruthy();
const preparedRecord = await transformPlaybookRunRecord({
action: OperationType.CREATE,
database: database!,
value: {
record: undefined,
raw: {
id: 'playbook_run_1',
playbook_id: 'playbook_1',
post_id: 'post_1',
owner_user_id: 'user_1',
team_id: 'team_1',
channel_id: 'channel_1',
create_at: 1620000000000,
end_at: 0,
name: 'Test Playbook Run',
description: 'This is a test playbook run',
is_active: true,
active_stage: 1,
active_stage_title: 'Stage 1',
participant_ids: ['user_1', 'user_2'],
summary: 'Test summary',
current_status: 'InProgress',
last_status_update_at: 1620000001000,
retrospective_enabled: true,
retrospective: 'Test retrospective',
retrospective_published_at: 1620000002000,
summary_modified_at: 0,
reported_user_id: '',
previous_reminder: 0,
status_update_enabled: false,
retrospective_was_canceled: false,
retrospective_reminder_interval_seconds: 0,
message_on_join: '',
category_name: '',
create_channel_member_on_new_participant: false,
remove_channel_member_on_removed_participant: false,
invited_user_ids: [],
invited_group_ids: [],
timeline_events: [],
broadcast_channel_ids: [],
webhook_on_creation_urls: [],
webhook_on_status_update_urls: [],
status_posts: [],
checklists: [],
metrics_data: [],
update_at: 1620000003000,
items_order: [],
},
},
});
expect(preparedRecord).toBeTruthy();
expect(preparedRecord!.collection.table).toBe(PLAYBOOK_RUN);
});
it('=> transformPlaybookRunRecord: should return a record of type PlaybookRun for UPDATE action', async () => {
expect.assertions(4);
const database = await createTestConnection({databaseName: 'playbook_run_prepare_records', setActive: true});
expect(database).toBeTruthy();
// Create an existing record to simulate the UPDATE action
let existingRecord: PlaybookRunModel | undefined;
await database!.write(async () => {
existingRecord = await database!.get<PlaybookRunModel>(PLAYBOOK_RUN).create((record) => {
record._raw.id = 'playbook_run_2';
record.playbookId = 'playbook_2';
record.postId = 'post_2';
record.ownerUserId = 'user_2';
record.teamId = 'team_2';
record.channelId = 'channel_2';
record.createAt = 1620000000000;
record.endAt = 0;
record.name = 'Existing Playbook Run';
record.description = 'This is an existing playbook run';
record.isActive = true;
record.activeStage = 1;
record.activeStageTitle = 'Stage 1';
record.participantIds = ['user_2', 'user_3'];
record.summary = 'Existing summary';
record.currentStatus = 'InProgress';
record.lastStatusUpdateAt = 1620000001000;
record.retrospectiveEnabled = true;
record.retrospective = 'Existing retrospective';
record.retrospectivePublishedAt = 1620000002000;
record.updateAt = 1620000003000;
});
});
const preparedRecord = await transformPlaybookRunRecord({
action: OperationType.UPDATE,
database: database!,
value: {
record: existingRecord,
raw: {
id: 'playbook_run_2',
playbook_id: 'playbook_2',
post_id: 'post_2',
owner_user_id: 'user_2',
team_id: 'team_2',
channel_id: 'channel_2',
create_at: 1620000000000,
end_at: 1620000003000,
name: 'Updated Playbook Run',
description: 'This is an updated playbook run',
is_active: false,
active_stage: 2,
active_stage_title: 'Stage 2',
participant_ids: ['user_2', 'user_4'],
summary: 'Updated summary',
current_status: 'Finished',
last_status_update_at: 1620000004000,
retrospective_enabled: false,
retrospective: 'Updated retrospective',
retrospective_published_at: 1620000005000,
summary_modified_at: 0,
reported_user_id: '',
previous_reminder: 0,
status_update_enabled: false,
retrospective_was_canceled: false,
retrospective_reminder_interval_seconds: 0,
message_on_join: '',
category_name: '',
create_channel_member_on_new_participant: false,
remove_channel_member_on_removed_participant: false,
invited_user_ids: [],
invited_group_ids: [],
timeline_events: [],
broadcast_channel_ids: [],
webhook_on_creation_urls: [],
webhook_on_status_update_urls: [],
status_posts: [],
checklists: [],
metrics_data: [],
update_at: 1620000004000,
items_order: [],
},
},
});
await database?.write(async () => {
await database?.batch(preparedRecord);
});
expect(preparedRecord).toBeTruthy();
expect(preparedRecord!.currentStatus).toBe('Finished');
expect(preparedRecord!.collection.table).toBe(PLAYBOOK_RUN);
});
it('=> transformPlaybookRunRecord: should throw an error for non-create action without an existing record', async () => {
expect.assertions(2);
const database = await createTestConnection({databaseName: 'playbook_run_prepare_records', setActive: true});
expect(database).toBeTruthy();
await expect(
transformPlaybookRunRecord({
action: OperationType.UPDATE,
database: database!,
value: {
record: undefined,
raw: {
id: 'playbook_run_3',
playbook_id: 'playbook_3',
post_id: 'post_3',
owner_user_id: 'user_3',
team_id: 'team_3',
channel_id: 'channel_3',
create_at: 1620000000000,
end_at: 0,
name: 'Invalid Playbook Run',
description: 'This should throw an error',
is_active: true,
active_stage: 1,
active_stage_title: 'Stage 1',
participant_ids: ['user_3', 'user_4'],
summary: 'Invalid summary',
current_status: 'InProgress',
last_status_update_at: 1620000001000,
retrospective_enabled: true,
retrospective: 'Invalid retrospective',
retrospective_published_at: 1620000002000,
summary_modified_at: 0,
reported_user_id: '',
previous_reminder: 0,
status_update_enabled: false,
retrospective_was_canceled: false,
retrospective_reminder_interval_seconds: 0,
message_on_join: '',
category_name: '',
create_channel_member_on_new_participant: false,
remove_channel_member_on_removed_participant: false,
invited_user_ids: [],
invited_group_ids: [],
timeline_events: [],
broadcast_channel_ids: [],
webhook_on_creation_urls: [],
webhook_on_status_update_urls: [],
status_posts: [],
checklists: [],
metrics_data: [],
update_at: 1620000005000,
items_order: [],
},
},
}),
).rejects.toThrow('Record not found for non create action');
});
it('=> transformPlaybookRunRecord: should keep most of the data if the partial run is empty', async () => {
const database = await createTestConnection({databaseName: 'playbook_run_prepare_records', setActive: true});
expect(database).toBeTruthy();
// Create an existing record to simulate the UPDATE action
let existingRecord: PlaybookRunModel | undefined;
await database!.write(async () => {
existingRecord = await database!.get<PlaybookRunModel>(PLAYBOOK_RUN).create((record) => {
record._raw.id = 'playbook_run_2';
record.playbookId = 'playbook_2';
record.postId = 'post_2';
record.ownerUserId = 'user_2';
record.teamId = 'team_2';
record.channelId = 'channel_2';
record.createAt = 1620000000000;
record.endAt = 0;
record.name = 'Existing Playbook Run';
record.description = 'This is an existing playbook run';
record.isActive = true;
record.activeStage = 1;
record.activeStageTitle = 'Stage 1';
record.participantIds = ['user_2', 'user_3'];
record.summary = 'Existing summary';
record.currentStatus = 'InProgress';
record.lastStatusUpdateAt = 1620000001000;
record.retrospectiveEnabled = true;
record.retrospective = 'Existing retrospective';
record.retrospectivePublishedAt = 1620000002000;
record.updateAt = 1620000003000;
record.lastSyncAt = 1620000003000;
record.itemsOrder = ['checklist_1', 'checklist_2'];
});
});
const lastSyncAt = existingRecord!.lastSyncAt;
const preparedRecord = await transformPlaybookRunRecord({
action: OperationType.UPDATE,
database: database!,
value: {
record: existingRecord,
raw: {
id: 'playbook_run_2',
update_at: 1620000004000,
},
},
});
await database?.write(async () => {
await database?.batch(preparedRecord);
});
expect(preparedRecord).toBeTruthy();
expect(preparedRecord.playbookId).toBe('playbook_2');
expect(preparedRecord.postId).toBe('post_2');
expect(preparedRecord.ownerUserId).toBe('user_2');
expect(preparedRecord.teamId).toBe('team_2');
expect(preparedRecord.channelId).toBe('channel_2');
expect(preparedRecord.createAt).toBe(1620000000000);
expect(preparedRecord.endAt).toBe(0);
expect(preparedRecord.name).toBe('Existing Playbook Run');
expect(preparedRecord.description).toBe('This is an existing playbook run');
expect(preparedRecord.isActive).toBe(true);
expect(preparedRecord.activeStage).toBe(1);
expect(preparedRecord.activeStageTitle).toBe('Stage 1');
expect(preparedRecord.participantIds).toEqual(['user_2', 'user_3']);
expect(preparedRecord.summary).toBe('Existing summary');
expect(preparedRecord.currentStatus).toBe('InProgress');
expect(preparedRecord.lastStatusUpdateAt).toBe(1620000001000);
expect(preparedRecord.retrospectiveEnabled).toBe(true);
expect(preparedRecord.retrospective).toBe('Existing retrospective');
expect(preparedRecord.retrospectivePublishedAt).toBe(1620000002000);
expect(preparedRecord.itemsOrder).toEqual(['checklist_1', 'checklist_2']);
// Changing values
expect(preparedRecord.updateAt).toBe(1620000004000);
expect(preparedRecord.lastSyncAt).toBeGreaterThan(lastSyncAt);
});
});
describe('*** PLAYBOOK_CHECKLIST Prepare Records Test ***', () => {
it('=> transformPlaybookChecklistRecord: should return a record of type PlaybookChecklist for CREATE action', async () => {
expect.assertions(3);
const database = await createTestConnection({databaseName: 'playbook_checklist_prepare_records', setActive: true});
expect(database).toBeTruthy();
const preparedRecord = await transformPlaybookChecklistRecord({
action: OperationType.CREATE,
database: database!,
value: {
record: undefined,
raw: {
id: 'checklist_1',
run_id: 'playbook_run_1',
title: 'Checklist 1',
update_at: 0,
items_order: [],
items: [],
},
},
});
expect(preparedRecord).toBeTruthy();
expect(preparedRecord!.collection.table).toBe(PLAYBOOK_TABLES.PLAYBOOK_CHECKLIST);
});
it('=> transformPlaybookChecklistRecord: should return a record of type PlaybookChecklist for UPDATE action', async () => {
expect.assertions(4);
const database = await createTestConnection({databaseName: 'playbook_checklist_prepare_records', setActive: true});
expect(database).toBeTruthy();
let existingRecord: PlaybookChecklistModel | undefined;
await database!.write(async () => {
existingRecord = await database!.get<PlaybookChecklistModel>(PLAYBOOK_TABLES.PLAYBOOK_CHECKLIST).create((record) => {
record._raw.id = 'checklist_2';
record.runId = 'playbook_run_2';
record.title = 'Existing Checklist';
record.updateAt = 0;
record.itemsOrder = [];
});
});
const preparedRecord = await transformPlaybookChecklistRecord({
action: OperationType.UPDATE,
database: database!,
value: {
record: existingRecord,
raw: {
id: 'checklist_2',
run_id: 'playbook_run_2',
title: 'Updated Checklist',
update_at: 0,
items_order: [],
items: [],
},
},
});
await database?.write(async () => {
await database?.batch(preparedRecord);
});
expect(preparedRecord).toBeTruthy();
expect(preparedRecord!.title).toBe('Updated Checklist');
expect(preparedRecord!.collection.table).toBe(PLAYBOOK_TABLES.PLAYBOOK_CHECKLIST);
});
it('=> transformPlaybookChecklistRecord: should throw an error for non-create action without an existing record', async () => {
expect.assertions(2);
const database = await createTestConnection({databaseName: 'playbook_checklist_prepare_records', setActive: true});
expect(database).toBeTruthy();
await expect(
transformPlaybookChecklistRecord({
action: OperationType.UPDATE,
database: database!,
value: {
record: undefined,
raw: {
id: 'checklist_3',
run_id: 'playbook_run_3',
title: 'Invalid Checklist',
update_at: 0,
items_order: [],
items: [],
},
},
}),
).rejects.toThrow('Record not found for non create action');
});
it('=> transformPlaybookChecklistRecord: should keep most of the data if the partial checklist is empty', async () => {
const database = await createTestConnection({databaseName: 'playbook_checklist_prepare_records', setActive: true});
expect(database).toBeTruthy();
// Create an existing record to simulate the UPDATE action
let existingRecord: PlaybookChecklistModel | undefined;
await database!.write(async () => {
existingRecord = await database!.get<PlaybookChecklistModel>(PLAYBOOK_TABLES.PLAYBOOK_CHECKLIST).create((record) => {
record._raw.id = 'checklist_2';
record.runId = 'playbook_run_2';
record.title = 'Existing Checklist';
record.updateAt = 1620000003000;
record.itemsOrder = ['item_1', 'item_2'];
record.lastSyncAt = 1620000003000;
});
});
const lastSyncAt = existingRecord!.lastSyncAt;
const preparedRecord = await transformPlaybookChecklistRecord({
action: OperationType.UPDATE,
database: database!,
value: {
record: existingRecord,
raw: {
id: 'checklist_2',
run_id: 'playbook_run_2',
update_at: 1620000004000,
},
},
});
await database?.write(async () => {
await database?.batch(preparedRecord);
});
expect(preparedRecord).toBeTruthy();
expect(preparedRecord!.title).toBe('Existing Checklist');
expect(preparedRecord!.runId).toBe('playbook_run_2');
expect(preparedRecord!.itemsOrder).toEqual(['item_1', 'item_2']);
// Changing values
expect(preparedRecord!.updateAt).toBe(1620000004000);
expect(preparedRecord!.lastSyncAt).toBeGreaterThan(lastSyncAt);
});
});
describe('*** PLAYBOOK_CHECKLIST_ITEM Prepare Records Test ***', () => {
it('=> transformPlaybookChecklistItemRecord: should return a record of type PlaybookChecklistItem for CREATE action', async () => {
expect.assertions(3);
const database = await createTestConnection({databaseName: 'playbook_checklist_item_prepare_records', setActive: true});
expect(database).toBeTruthy();
const preparedRecord = await transformPlaybookChecklistItemRecord({
action: OperationType.CREATE,
database: database!,
value: {
record: undefined,
raw: {
id: 'checklist_item_1',
checklist_id: 'checklist_1',
title: 'Checklist Item 1',
state: '',
state_modified: 1620000000000,
assignee_id: 'user_1',
assignee_modified: 1620000001000,
command: '/test-command',
command_last_run: 1620000002000,
description: 'Test description',
due_date: 1620000003000,
completed_at: 0,
task_actions: [],
update_at: 0,
},
},
});
expect(preparedRecord).toBeTruthy();
expect(preparedRecord!.collection.table).toBe(PLAYBOOK_TABLES.PLAYBOOK_CHECKLIST_ITEM);
});
it('=> transformPlaybookChecklistItemRecord: should return a record of type PlaybookChecklistItem for UPDATE action', async () => {
expect.assertions(4);
const database = await createTestConnection({databaseName: 'playbook_checklist_item_prepare_records', setActive: true});
expect(database).toBeTruthy();
let existingRecord: PlaybookChecklistItemModel | undefined;
await database!.write(async () => {
existingRecord = await database!.get<PlaybookChecklistItemModel>(PLAYBOOK_TABLES.PLAYBOOK_CHECKLIST_ITEM).create((record) => {
record._raw.id = 'checklist_item_2';
record.checklistId = 'checklist_2';
record.title = 'Existing Checklist Item';
record.state = '';
record.stateModified = 1620000000000;
record.assigneeId = 'user_2';
record.assigneeModified = 1620000001000;
record.command = '/existing-command';
record.commandLastRun = 1620000002000;
record.description = 'Existing description';
record.dueDate = 1620000003000;
record.completedAt = 0;
record.taskActions = [];
record.updateAt = 0;
});
});
const preparedRecord = await transformPlaybookChecklistItemRecord({
action: OperationType.UPDATE,
database: database!,
value: {
record: existingRecord,
raw: {
id: 'checklist_item_2',
checklist_id: 'checklist_2',
title: 'Updated Checklist Item',
state: 'closed',
state_modified: 1620000004000,
assignee_id: 'user_3',
assignee_modified: 1620000005000,
command: '/updated-command',
command_last_run: 1620000006000,
description: 'Updated description',
due_date: 1620000007000,
completed_at: 1620000008000,
task_actions: [],
update_at: 0,
},
},
});
await database?.write(async () => {
await database?.batch(preparedRecord);
});
expect(preparedRecord).toBeTruthy();
expect(preparedRecord!.title).toBe('Updated Checklist Item');
expect(preparedRecord!.collection.table).toBe(PLAYBOOK_TABLES.PLAYBOOK_CHECKLIST_ITEM);
});
it('=> transformPlaybookChecklistItemRecord: should throw an error for non-create action without an existing record', async () => {
expect.assertions(2);
const database = await createTestConnection({databaseName: 'playbook_checklist_item_prepare_records', setActive: true});
expect(database).toBeTruthy();
await expect(
transformPlaybookChecklistItemRecord({
action: OperationType.UPDATE,
database: database!,
value: {
record: undefined,
raw: {
id: 'checklist_item_3',
checklist_id: 'checklist_3',
title: 'Invalid Checklist Item',
state: '',
state_modified: 1620000000000,
assignee_id: 'user_4',
assignee_modified: 1620000001000,
command: '/invalid-command',
command_last_run: 1620000002000,
description: 'Invalid description',
due_date: 1620000003000,
completed_at: 0,
task_actions: [],
update_at: 0,
},
},
}),
).rejects.toThrow('Record not found for non create action');
});
it('=> transformPlaybookChecklistItemRecord: should keep most of the data if the partial checklist item is empty', async () => {
const database = await createTestConnection({databaseName: 'playbook_checklist_item_prepare_records', setActive: true});
expect(database).toBeTruthy();
// Create an existing record to simulate the UPDATE action
let existingRecord: PlaybookChecklistItemModel | undefined;
await database!.write(async () => {
existingRecord = await database!.get<PlaybookChecklistItemModel>(PLAYBOOK_TABLES.PLAYBOOK_CHECKLIST_ITEM).create((record) => {
record._raw.id = 'checklist_item_2';
record.checklistId = 'checklist_2';
record.title = 'Existing Checklist Item';
record.state = '';
record.stateModified = 1620000000000;
record.assigneeId = 'user_2';
record.assigneeModified = 1620000001000;
record.command = '/existing-command';
record.commandLastRun = 1620000002000;
record.description = 'Existing description';
record.dueDate = 1620000003000;
record.completedAt = 0;
record.taskActions = [];
record.updateAt = 0;
record.lastSyncAt = 1620000003000;
});
});
const lastSyncAt = existingRecord!.lastSyncAt;
const preparedRecord = await transformPlaybookChecklistItemRecord({
action: OperationType.UPDATE,
database: database!,
value: {
record: existingRecord,
raw: {
id: 'checklist_item_2',
checklist_id: 'checklist_2',
update_at: 1620000004000,
},
},
});
await database?.write(async () => {
await database?.batch(preparedRecord);
});
expect(preparedRecord).toBeTruthy();
expect(preparedRecord!.title).toBe('Existing Checklist Item');
expect(preparedRecord!.checklistId).toBe('checklist_2');
expect(preparedRecord!.state).toBe('');
expect(preparedRecord!.stateModified).toBe(1620000000000);
expect(preparedRecord!.assigneeId).toBe('user_2');
expect(preparedRecord!.assigneeModified).toBe(1620000001000);
expect(preparedRecord!.command).toBe('/existing-command');
expect(preparedRecord!.commandLastRun).toBe(1620000002000);
expect(preparedRecord!.description).toBe('Existing description');
expect(preparedRecord!.dueDate).toBe(1620000003000);
expect(preparedRecord!.completedAt).toBe(0);
expect(preparedRecord!.taskActions).toEqual([]);
// Changing values
expect(preparedRecord!.updateAt).toBe(1620000004000);
expect(preparedRecord!.lastSyncAt).toBeGreaterThan(lastSyncAt);
});
});

View file

@ -0,0 +1,140 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {OperationType} from '@constants/database';
import {prepareBaseRecord} from '@database/operator/server_data_operator/transformers/index';
import {PLAYBOOK_TABLES} from '@playbooks/constants/database';
import type PlaybookChecklistModel from '@playbooks/types/database/models/playbook_checklist';
import type PlaybookChecklistItemModel from '@playbooks/types/database/models/playbook_checklist_item';
import type PlaybookRunModel from '@playbooks/types/database/models/playbook_run';
import type{TransformerArgs} from '@typings/database/database';
const {PLAYBOOK_RUN, PLAYBOOK_CHECKLIST, PLAYBOOK_CHECKLIST_ITEM} = PLAYBOOK_TABLES;
/**
* transformPlaybookRunRecord: Prepares a record of the SERVER database 'PlaybookRun' table for update or create actions.
* @param {TransformerArgs} transformerArgs
* @param {Database} transformerArgs.database
* @param {RecordPair} transformerArgs.value
* @returns {Promise<PlaybookRunModel>}
*/
export const transformPlaybookRunRecord = ({action, database, value}: TransformerArgs<PlaybookRunModel, PartialPlaybookRun>): Promise<PlaybookRunModel> => {
const raw = value.raw;
const record = value.record;
const isCreateAction = action === OperationType.CREATE;
if (!isCreateAction && !value.record) {
return Promise.reject(new Error('Record not found for non create action'));
}
// If isCreateAction is true, we will use the id (API response) from the RAW, else we shall use the existing record id from the database
const fieldsMapper = (run: PlaybookRunModel) => {
run._raw.id = isCreateAction ? (raw?.id ?? run.id) : run.id;
run.playbookId = raw.playbook_id ?? record?.playbookId ?? '';
run.postId = raw.post_id ?? record?.postId ?? null;
run.ownerUserId = raw.owner_user_id ?? record?.ownerUserId ?? '';
run.teamId = raw.team_id ?? record?.teamId ?? '';
run.channelId = raw.channel_id ?? record?.channelId ?? '';
run.createAt = raw.create_at ?? record?.createAt ?? 0;
run.endAt = raw.end_at ?? (record?.endAt ?? 0);
run.name = raw.name ?? record?.name ?? '';
run.description = raw.description ?? record?.description ?? '';
run.isActive = raw.is_active ?? record?.isActive ?? false;
run.activeStage = raw.active_stage ?? record?.activeStage ?? 0;
run.activeStageTitle = raw.active_stage_title ?? record?.activeStageTitle ?? '';
run.participantIds = raw.participant_ids ?? record?.participantIds ?? [];
run.summary = raw.summary ?? record?.summary ?? '';
run.currentStatus = raw.current_status ?? record?.currentStatus ?? 'InProgress';
run.lastStatusUpdateAt = raw.last_status_update_at ?? record?.lastStatusUpdateAt ?? 0;
run.previousReminder = raw.previous_reminder ?? record?.previousReminder ?? 0;
run.retrospectiveEnabled = raw.retrospective_enabled ?? record?.retrospectiveEnabled ?? false;
run.retrospective = raw.retrospective ?? record?.retrospective ?? '';
run.retrospectivePublishedAt = raw.retrospective_published_at ?? record?.retrospectivePublishedAt ?? 0;
run.updateAt = raw.update_at ?? record?.updateAt ?? raw.create_at ?? record?.createAt ?? 0;
run.lastSyncAt = Date.now();
run.itemsOrder = raw.items_order ?? record?.itemsOrder ?? [];
};
return prepareBaseRecord({
action,
database,
tableName: PLAYBOOK_RUN,
value,
fieldsMapper,
});
};
/**
* transformPlaybookChecklistRecord: Prepares a record of the SERVER database 'PlaybookChecklist' table for update or create actions.
* @param {TransformerArgs} transformerArgs
* @param {Database} transformerArgs.database
* @param {RecordPair} transformerArgs.value
* @returns {Promise<PlaybookChecklistModel>}
*/
export const transformPlaybookChecklistRecord = ({action, database, value}: TransformerArgs<PlaybookChecklistModel, PartialChecklist>): Promise<PlaybookChecklistModel> => {
const raw = value.raw;
const record = value.record;
const isCreateAction = action === OperationType.CREATE;
if (!isCreateAction && !record) {
return Promise.reject(new Error('Record not found for non create action'));
}
const fieldsMapper = (checklist: PlaybookChecklistModel) => {
checklist._raw.id = isCreateAction ? (raw?.id ?? checklist.id) : record!.id;
checklist.runId = raw.run_id ?? record?.runId;
checklist.title = raw.title ?? record?.title ?? '';
checklist.updateAt = raw.update_at ?? record?.updateAt ?? 0;
checklist.lastSyncAt = Date.now();
checklist.itemsOrder = raw.items_order ?? record?.itemsOrder ?? [];
};
return prepareBaseRecord({
action,
database,
tableName: PLAYBOOK_CHECKLIST,
value,
fieldsMapper,
});
};
/**
* transformPlaybookChecklistItemRecord: Prepares a record of the SERVER database 'PlaybookChecklistItem' table for update or create actions.
* @param {TransformerArgs} transformerArgs
* @param {Database} transformerArgs.database
* @param {RecordPair} transformerArgs.value
* @returns {Promise<PlaybookChecklistItemModel>}
*/
export const transformPlaybookChecklistItemRecord = ({action, database, value}: TransformerArgs<PlaybookChecklistItemModel, PartialChecklistItem>): Promise<PlaybookChecklistItemModel> => {
const raw = value.raw;
const record = value.record;
const isCreateAction = action === OperationType.CREATE;
if (!isCreateAction && !record) {
return Promise.reject(new Error('Record not found for non create action'));
}
const fieldsMapper = (item: PlaybookChecklistItemModel) => {
item._raw.id = isCreateAction ? (raw?.id ?? item.id) : record!.id;
item.checklistId = raw.checklist_id ?? record?.checklistId;
item.title = raw.title ?? record?.title ?? '';
item.state = raw.state ?? record?.state ?? '';
item.stateModified = raw.state_modified ?? record?.stateModified ?? 0;
item.assigneeId = raw.assignee_id ?? record?.assigneeId ?? null;
item.assigneeModified = raw.assignee_modified ?? record?.assigneeModified ?? 0;
item.command = raw.command ?? record?.command ?? null;
item.commandLastRun = raw.command_last_run ?? record?.commandLastRun ?? 0;
item.description = raw.description ?? record?.description ?? '';
item.dueDate = raw.due_date ?? record?.dueDate ?? 0;
item.completedAt = raw.completed_at ?? record?.completedAt ?? 0;
item.taskActions = raw.task_actions ?? record?.taskActions ?? [];
item.updateAt = raw.update_at ?? record?.updateAt ?? 0;
item.lastSyncAt = Date.now();
};
return prepareBaseRecord({
action,
database,
tableName: PLAYBOOK_CHECKLIST_ITEM,
value,
fieldsMapper,
});
};

View file

@ -0,0 +1,77 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import DatabaseManager from '@database/manager';
import TestHelper from '@test/test_helper';
import {
queryPlaybookChecklistByRun,
getPlaybookChecklistById,
} from './checklist';
import type ServerDataOperator from '@database/operator/server_data_operator';
describe('Checklist Queries', () => {
let operator: ServerDataOperator;
beforeEach(async () => {
await DatabaseManager.init(['checklist.test.com']);
operator = DatabaseManager.serverDatabases['checklist.test.com']!.operator;
});
afterEach(async () => {
await DatabaseManager.destroyServerDatabase('checklist.test.com');
});
describe('queryPlaybookChecklistByRun', () => {
it('should query checklists by runId', async () => {
const runId = 'run123';
const mockChecklists = [
TestHelper.createPlaybookChecklist(runId, 0, 0),
TestHelper.createPlaybookChecklist(runId, 0, 1),
].map((checklist, index) => ({
...checklist,
run_id: runId,
order: index,
})).reverse();
await operator.handlePlaybookChecklist({
checklists: mockChecklists,
prepareRecordsOnly: false,
});
const result = queryPlaybookChecklistByRun(operator.database, runId);
const fetchedChecklists = await result.fetch();
expect(fetchedChecklists.length).toBe(2);
expect(fetchedChecklists).toContainEqual(expect.objectContaining({id: mockChecklists[0].id}));
expect(fetchedChecklists).toContainEqual(expect.objectContaining({id: mockChecklists[1].id}));
});
});
describe('getPlaybookChecklistById', () => {
it('should return a checklist if found', async () => {
const mockChecklist = {
...TestHelper.createPlaybookChecklist('run123', 0, 0),
run_id: 'run123',
order: 0,
};
await operator.handlePlaybookChecklist({
checklists: [mockChecklist],
prepareRecordsOnly: false,
});
const result = await getPlaybookChecklistById(operator.database, mockChecklist.id);
expect(result).toBeDefined();
expect(result!.id).toBe(mockChecklist.id);
});
it('should return undefined if checklist is not found', async () => {
const result = await getPlaybookChecklistById(operator.database, 'nonexistent');
expect(result).toBeUndefined();
});
});
});

View file

@ -0,0 +1,26 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Q, type Database} from '@nozbe/watermelondb';
import {PLAYBOOK_TABLES} from '@playbooks/constants/database';
import type PlaybookChecklistModel from '@playbooks/types/database/models/playbook_checklist';
const {PLAYBOOK_CHECKLIST} = PLAYBOOK_TABLES;
export const queryPlaybookChecklistByRun = (database: Database, runId: string) => {
return database.get<PlaybookChecklistModel>(PLAYBOOK_CHECKLIST).query(
Q.where('run_id', runId),
);
};
export const getPlaybookChecklistById = async (database: Database, id: string) => {
try {
const checklist = await database.get<PlaybookChecklistModel>(PLAYBOOK_CHECKLIST).find(id);
return checklist;
} catch {
return undefined;
}
};

View file

@ -0,0 +1,76 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import DatabaseManager from '@database/manager';
import TestHelper from '@test/test_helper';
import {
queryPlaybookChecklistItemsByChecklists,
getPlaybookChecklistItemById,
} from './item';
import type ServerDataOperator from '@database/operator/server_data_operator';
describe('Checklist Item Queries', () => {
let operator: ServerDataOperator;
beforeEach(async () => {
await DatabaseManager.init(['item.test.com']);
operator = DatabaseManager.serverDatabases['item.test.com']!.operator;
});
afterEach(async () => {
await DatabaseManager.destroyServerDatabase('item.test.com');
});
describe('queryPlaybookChecklistItemsByChecklist', () => {
it('should query checklist items by checklistId', async () => {
const checklistId = 'checklist123';
const mockItems = [
TestHelper.createPlaybookItem(checklistId, 0),
TestHelper.createPlaybookItem(checklistId, 1),
].map((item) => ({
...item,
checklist_id: checklistId,
}));
await operator.handlePlaybookChecklistItem({
items: mockItems,
prepareRecordsOnly: false,
});
const result = queryPlaybookChecklistItemsByChecklists(operator.database, [checklistId]);
const fetchedItems = await result.fetch();
expect(fetchedItems.length).toBe(2);
const fetchedIds = fetchedItems.map((item) => item.id);
expect(fetchedIds).toContain(mockItems[0].id);
expect(fetchedIds).toContain(mockItems[1].id);
});
});
describe('getPlaybookChecklistItemById', () => {
it('should return a checklist item if found', async () => {
const mockItem = {
...TestHelper.createPlaybookItem('checklist123', 0),
checklist_id: 'checklist123',
};
await operator.handlePlaybookChecklistItem({
items: [mockItem],
prepareRecordsOnly: false,
});
const result = await getPlaybookChecklistItemById(operator.database, mockItem.id);
expect(result).toBeDefined();
expect(result!.id).toBe(mockItem.id);
});
it('should return undefined if checklist item is not found', async () => {
const result = await getPlaybookChecklistItemById(operator.database, 'nonexistent');
expect(result).toBeUndefined();
});
});
});

View file

@ -0,0 +1,25 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Q, type Database} from '@nozbe/watermelondb';
import {PLAYBOOK_TABLES} from '@playbooks/constants/database';
import type PlaybookChecklistItemModel from '@playbooks/types/database/models/playbook_checklist_item';
const {PLAYBOOK_CHECKLIST_ITEM} = PLAYBOOK_TABLES;
export const queryPlaybookChecklistItemsByChecklists = (database: Database, checklistId: string[]) => {
return database.get<PlaybookChecklistItemModel>(PLAYBOOK_CHECKLIST_ITEM).query(
Q.where('checklist_id', Q.oneOf(checklistId)),
);
};
export const getPlaybookChecklistItemById = async (database: Database, id: string) => {
try {
const item = await database.get<PlaybookChecklistItemModel>(PLAYBOOK_CHECKLIST_ITEM).find(id);
return item;
} catch {
return undefined;
}
};

View file

@ -0,0 +1,424 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {MM_TABLES} from '@constants/database';
import DatabaseManager from '@database/manager';
import TestHelper from '@test/test_helper';
import {
queryPlaybookRunsPerChannel,
getPlaybookRunById,
observePlaybookRunById,
observePlaybookRunProgress,
getLastPlaybookRunsFetchAt,
queryParticipantsFromAPIRun,
} from './run';
import type ServerDataOperator from '@database/operator/server_data_operator';
describe('Playbook Run Queries', () => {
let operator: ServerDataOperator;
beforeEach(async () => {
await DatabaseManager.init(['playbookRun.test.com']);
operator = DatabaseManager.serverDatabases['playbookRun.test.com']!.operator;
});
afterEach(async () => {
await DatabaseManager.destroyServerDatabase('playbookRun.test.com');
});
describe('queryPlaybookRunsPerChannel', () => {
it('should query playbook runs for a channel without finished status', async () => {
const channelId = 'channel1';
const mockRuns = TestHelper.createPlaybookRuns(2, 0, 0).map((run, index) => ({
...run,
channel_id: channelId,
end_at: index === 0 ? 0 : 1620000000000, // First run is not finished, second is finished
})).reverse(); // Reverse to ensure the order matches sort by create_at desc
await operator.handlePlaybookRun({
runs: mockRuns,
prepareRecordsOnly: false,
removeAssociatedRecords: false,
});
const result = queryPlaybookRunsPerChannel(operator.database, channelId);
const fetchedRuns = await result.fetch();
expect(fetchedRuns.length).toBe(2);
expect(fetchedRuns[0].id).toBe(mockRuns[0].id);
expect(fetchedRuns[1].id).toBe(mockRuns[1].id);
});
it('should query playbook runs for a channel with finished status', async () => {
const channelId = 'channel2';
const mockRuns = TestHelper.createPlaybookRuns(2, 0, 0).map((run, index) => ({
...run,
channel_id: channelId,
end_at: index === 0 ? 0 : 1620000000000, // First run is not finished, second is finished
})).reverse(); // Reverse to ensure the order matches sort by create_at desc
await operator.handlePlaybookRun({
runs: mockRuns,
prepareRecordsOnly: false,
removeAssociatedRecords: false,
});
const result = queryPlaybookRunsPerChannel(operator.database, channelId, true);
const fetchedRuns = await result.fetch();
expect(fetchedRuns.length).toBe(1);
expect(fetchedRuns[0].id).toBe(mockRuns[0].id); // Only the finished run
});
it('should query playbook runs for a channel with unfinished status', async () => {
const channelId = 'channel3';
const mockRuns = TestHelper.createPlaybookRuns(2, 0, 0).map((run, index) => ({
...run,
channel_id: channelId,
end_at: index === 0 ? 0 : 1620000000000, // First run is not finished, second is finished
})).reverse(); // Reverse to ensure the order matches sort by create_at desc
await operator.handlePlaybookRun({
runs: mockRuns,
prepareRecordsOnly: false,
removeAssociatedRecords: false,
});
const result = queryPlaybookRunsPerChannel(operator.database, channelId, false);
const fetchedRuns = await result.fetch();
expect(fetchedRuns.length).toBe(1);
expect(fetchedRuns[0].id).toBe(mockRuns[1].id); // Only the unfinished run
});
});
describe('getPlaybookRunById', () => {
it('should return the playbook run if found', async () => {
const mockRuns = TestHelper.createPlaybookRuns(1, 0, 0);
await operator.handlePlaybookRun({
runs: mockRuns,
prepareRecordsOnly: false,
removeAssociatedRecords: false,
});
const result = await getPlaybookRunById(operator.database, mockRuns[0].id);
expect(result).toBeDefined();
expect(result!.id).toBe(mockRuns[0].id);
});
it('should return undefined if the playbook run is not found', async () => {
const result = await getPlaybookRunById(operator.database, 'nonexistent_run');
expect(result).toBeUndefined();
});
});
describe('observePlaybookRunById', () => {
it('should observe the playbook run if found', async () => {
const subscriptionNext = jest.fn();
const mockRuns = TestHelper.createPlaybookRuns(1, 0, 0);
await operator.handlePlaybookRun({
runs: mockRuns,
prepareRecordsOnly: false,
removeAssociatedRecords: false,
});
const result = observePlaybookRunById(operator.database, mockRuns[0].id);
result.subscribe({next: subscriptionNext});
expect(subscriptionNext).toHaveBeenCalledWith(expect.objectContaining({
id: mockRuns[0].id,
}));
});
it('should return undefined if the playbook run is not found', async () => {
const subscriptionNext = jest.fn();
const result = observePlaybookRunById(operator.database, 'nonexistent_run');
result.subscribe({next: subscriptionNext});
expect(subscriptionNext).toHaveBeenCalledWith(undefined);
});
});
describe('observePlaybookRunProgress', () => {
it('should return 0 when there are no checklist', async () => {
const subscriptionNext = jest.fn();
const mockRuns = TestHelper.createPlaybookRuns(1, 0, 0);
await operator.handlePlaybookRun({
runs: mockRuns,
prepareRecordsOnly: false,
removeAssociatedRecords: false,
});
const result = observePlaybookRunProgress(operator.database, mockRuns[0].id);
result.subscribe({next: subscriptionNext});
expect(subscriptionNext).toHaveBeenCalledWith(0);
});
it('should return 0 when there are no checklist items', async () => {
const subscriptionNext = jest.fn();
const mockRuns = TestHelper.createPlaybookRuns(1, 1, 0);
await operator.handlePlaybookRun({
runs: mockRuns,
prepareRecordsOnly: false,
removeAssociatedRecords: false,
processChildren: true,
});
const result = observePlaybookRunProgress(operator.database, mockRuns[0].id);
result.subscribe({next: subscriptionNext});
expect(subscriptionNext).toHaveBeenCalledWith(0);
});
it('should return 100 when all checklist items are completed', async () => {
const subscriptionNext = jest.fn();
const mockRuns = TestHelper.createPlaybookRuns(1, 1, 2);
mockRuns[0].checklists[0].items.forEach((item) => {
item.state = 'closed';
item.completed_at = Date.now();
});
await operator.handlePlaybookRun({
runs: mockRuns,
prepareRecordsOnly: false,
removeAssociatedRecords: false,
processChildren: true,
});
const result = observePlaybookRunProgress(operator.database, mockRuns[0].id);
result.subscribe({next: subscriptionNext});
expect(subscriptionNext).toHaveBeenCalledWith(100.00);
});
it('should return correct progress when some checklist items are completed', async () => {
const subscriptionNext = jest.fn();
const mockRuns = TestHelper.createPlaybookRuns(1, 1, 9);
if (mockRuns[0].checklists[0].items.length < 3) {
const index = mockRuns[0].checklists[0].items.length - 1;
mockRuns[0].checklists[0].items.push(
TestHelper.createPlaybookItem(mockRuns[0].checklists[0].id, index + 1),
TestHelper.createPlaybookItem(mockRuns[0].checklists[0].id, index + 2),
TestHelper.createPlaybookItem(mockRuns[0].checklists[0].id, index + 3),
);
}
const totalItems = mockRuns[0].checklists[0].items.length;
// mark only a third of items as completed
mockRuns[0].checklists[0].items.forEach((item, index) => {
if (index < (totalItems * 2) / 3) {
item.state = 'closed';
item.completed_at = Date.now();
}
});
await operator.handlePlaybookRun({
runs: mockRuns,
prepareRecordsOnly: false,
removeAssociatedRecords: false,
processChildren: true,
});
const result = observePlaybookRunProgress(operator.database, mockRuns[0].id);
result.subscribe({next: subscriptionNext});
expect(subscriptionNext).toHaveBeenCalledWith(expect.any(Number));
const progress = subscriptionNext.mock.calls[0][0];
expect(progress).toBeGreaterThan(0);
expect(progress).toBeLessThan(100.00);
});
it('should return 0 when all checklist items are open', async () => {
const subscriptionNext = jest.fn();
const mockRuns = TestHelper.createPlaybookRuns(1, 1, 2);
await operator.handlePlaybookRun({
runs: mockRuns,
prepareRecordsOnly: false,
removeAssociatedRecords: false,
processChildren: true,
});
const result = observePlaybookRunProgress(operator.database, mockRuns[0].id);
result.subscribe({next: subscriptionNext});
expect(subscriptionNext).toHaveBeenCalledWith(0);
});
});
describe('getLastPlaybookRunsFetchAt', () => {
it('should return the lastPlaybookRunsFetchAt value when channel exists', async () => {
const channelId = 'channel1';
const lastPlaybookRunsFetchAt = 1620000000000;
await operator.handleMyChannel({
channels: [TestHelper.fakeChannel({id: channelId})],
myChannels: [TestHelper.fakeMyChannel({channel_id: channelId})],
prepareRecordsOnly: false,
});
// Update the record with the lastPlaybookRunsFetchAt value
const myChannelRecord = await operator.database.get(MM_TABLES.SERVER.MY_CHANNEL).find(channelId);
await operator.database.write(async () => {
await myChannelRecord.update((record: any) => {
record.lastPlaybookRunsFetchAt = lastPlaybookRunsFetchAt;
});
});
const result = await getLastPlaybookRunsFetchAt(operator.database, channelId);
expect(result).toBe(lastPlaybookRunsFetchAt);
});
it('should return 0 when channel does not exist', async () => {
const result = await getLastPlaybookRunsFetchAt(operator.database, 'nonexistent_channel');
expect(result).toBe(0);
});
it('should return 0 when channel exists but lastPlaybookRunsFetchAt is not set', async () => {
const channelId = 'channel2';
await operator.handleMyChannel({
channels: [TestHelper.fakeChannel({id: channelId})],
myChannels: [TestHelper.fakeMyChannel({channel_id: channelId})],
prepareRecordsOnly: false,
});
const result = await getLastPlaybookRunsFetchAt(operator.database, channelId);
expect(result).toBe(0);
});
});
describe('queryParticipantsFromAPIRun', () => {
it('should return participants excluding the owner', async () => {
const ownerId = 'owner-user-id';
const participant1Id = 'participant-1-id';
const participant2Id = 'participant-2-id';
const participant3Id = 'participant-3-id';
// Create mock users
const mockUsers = [
TestHelper.fakeUser({id: ownerId, username: 'owner'}),
TestHelper.fakeUser({id: participant1Id, username: 'participant1'}),
TestHelper.fakeUser({id: participant2Id, username: 'participant2'}),
TestHelper.fakeUser({id: participant3Id, username: 'participant3'}),
];
await operator.handleUsers({
users: mockUsers,
prepareRecordsOnly: false,
});
// Create a mock playbook run with participants including the owner
const mockRun = TestHelper.fakePlaybookRun({
owner_user_id: ownerId,
participant_ids: [ownerId, participant1Id, participant2Id, participant3Id],
});
const result = queryParticipantsFromAPIRun(operator.database, mockRun);
const participants = await result.fetch();
// Should return 3 participants (excluding the owner)
expect(participants).toHaveLength(3);
// Should not include the owner
const participantIds = participants.map((p) => p.id);
expect(participantIds).toContain(participant1Id);
expect(participantIds).toContain(participant2Id);
expect(participantIds).toContain(participant3Id);
expect(participantIds).not.toContain(ownerId);
});
it('should return empty array when no participants exist', async () => {
const ownerId = 'owner-user-id';
// Create only the owner user
const mockUser = TestHelper.fakeUser({id: ownerId, username: 'owner'});
await operator.handleUsers({
users: [mockUser],
prepareRecordsOnly: false,
});
// Create a mock playbook run with only the owner as participant
const mockRun = TestHelper.fakePlaybookRun({
owner_user_id: ownerId,
participant_ids: [ownerId],
});
const result = queryParticipantsFromAPIRun(operator.database, mockRun);
const participants = await result.fetch();
// Should return empty array since owner is excluded
expect(participants).toHaveLength(0);
});
it('should return empty array when participants do not exist in database', async () => {
const ownerId = 'owner-user-id';
const nonExistentParticipantId = 'non-existent-participant-id';
// Create only the owner user
const mockUser = TestHelper.fakeUser({id: ownerId, username: 'owner'});
await operator.handleUsers({
users: [mockUser],
prepareRecordsOnly: false,
});
// Create a mock playbook run with non-existent participant
const mockRun = TestHelper.fakePlaybookRun({
owner_user_id: ownerId,
participant_ids: [ownerId, nonExistentParticipantId],
});
const result = queryParticipantsFromAPIRun(operator.database, mockRun);
const participants = await result.fetch();
// Should return empty array since owner is excluded and participant doesn't exist
expect(participants).toHaveLength(0);
});
it('should handle case where owner is not in participant_ids', async () => {
const ownerId = 'owner-user-id';
const participant1Id = 'participant-1-id';
const participant2Id = 'participant-2-id';
// Create mock users
const mockUsers = [
TestHelper.fakeUser({id: ownerId, username: 'owner'}),
TestHelper.fakeUser({id: participant1Id, username: 'participant1'}),
TestHelper.fakeUser({id: participant2Id, username: 'participant2'}),
];
await operator.handleUsers({
users: mockUsers,
prepareRecordsOnly: false,
});
// Create a mock playbook run where owner is not in participant_ids
const mockRun = TestHelper.fakePlaybookRun({
owner_user_id: ownerId,
participant_ids: [participant1Id, participant2Id], // Owner not included
});
const result = queryParticipantsFromAPIRun(operator.database, mockRun);
const participants = await result.fetch();
// Should return all participants since owner is not in the list
expect(participants).toHaveLength(2);
const participantIds = participants.map((p) => p.id);
expect(participantIds).toContain(participant1Id);
expect(participantIds).toContain(participant2Id);
expect(participantIds).not.toContain(ownerId);
});
});
});

View file

@ -0,0 +1,74 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Q, type Database} from '@nozbe/watermelondb';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {MM_TABLES} from '@constants/database';
import {PLAYBOOK_TABLES} from '@playbooks/constants/database';
import {getChecklistProgress} from '@playbooks/utils/progress';
import {queryUsersById} from '@queries/servers/user';
import type PlaybookChecklistItemModel from '@playbooks/types/database/models/playbook_checklist_item';
import type PlaybookRunModel from '@playbooks/types/database/models/playbook_run';
import type MyChannelModel from '@typings/database/models/servers/my_channel';
const {PLAYBOOK_RUN, PLAYBOOK_CHECKLIST, PLAYBOOK_CHECKLIST_ITEM} = PLAYBOOK_TABLES;
const {MY_CHANNEL} = MM_TABLES.SERVER;
export const queryPlaybookRunsPerChannel = (database: Database, channelId: string, finished?: boolean) => {
const conditions = [Q.where('channel_id', channelId)];
if (finished != null) {
conditions.push(Q.where('end_at', finished ? Q.notEq(0) : Q.eq(0)));
}
return database.get<PlaybookRunModel>(PLAYBOOK_RUN).query(
Q.and(...conditions),
Q.sortBy('create_at', 'desc'),
);
};
export const getPlaybookRunById = async (database: Database, id: string) => {
try {
const run = await database.get<PlaybookRunModel>(PLAYBOOK_RUN).find(id);
return run;
} catch {
return undefined;
}
};
export const observePlaybookRunById = (database: Database, id: string) => {
return database.get<PlaybookRunModel>(PLAYBOOK_RUN).query(
Q.where('id', id),
Q.take(1),
).observe().pipe(
switchMap((run) => {
return run.length ? run[0].observe() : of$(undefined);
}),
);
};
export const observePlaybookRunProgress = (database: Database, runId: string) => {
const items = database.get<PlaybookChecklistItemModel>(PLAYBOOK_CHECKLIST_ITEM).query(
Q.on(PLAYBOOK_CHECKLIST, 'run_id', runId),
).observeWithColumns(['state']);
return items.pipe(
switchMap((checklistsItems) => {
return of$(getChecklistProgress(checklistsItems).progress);
}),
);
};
export const getLastPlaybookRunsFetchAt = async (database: Database, channelId: string) => {
const myChannel = await database.get<MyChannelModel>(MY_CHANNEL).query(
Q.where('id', channelId),
).fetch();
return myChannel[0]?.lastPlaybookRunsFetchAt || 0;
};
export const queryParticipantsFromAPIRun = (database: Database, run: PlaybookRun) => {
const participantsIds = run.participant_ids.filter((id) => id !== run.owner_user_id);
return queryUsersById(database, participantsIds);
};

View file

@ -0,0 +1,154 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
import {MINIMUM_MAJOR_VERSION, MINIMUM_MINOR_VERSION, MINIMUM_PATCH_VERSION} from '@playbooks/constants/version';
import {observeIsPlaybooksEnabled} from './version';
import type ServerDataOperator from '@database/operator/server_data_operator';
const MINIMUM_VERSION = `${MINIMUM_MAJOR_VERSION}.${MINIMUM_MINOR_VERSION}.${MINIMUM_PATCH_VERSION}`;
describe('Playbook Version Queries', () => {
let operator: ServerDataOperator;
beforeEach(async () => {
await DatabaseManager.init(['version.test.com']);
operator = DatabaseManager.serverDatabases['version.test.com']!.operator;
});
afterEach(async () => {
await DatabaseManager.destroyServerDatabase('version.test.com');
});
describe('observeIsPlaybooksEnabled', () => {
it('should return false when no playbooks version is set', async () => {
const subscriptionNext = jest.fn();
const result = observeIsPlaybooksEnabled(operator.database);
result.subscribe({next: subscriptionNext});
// Subscription always returns the first value
expect(subscriptionNext).toHaveBeenCalledWith(false);
});
it(`should return true when playbooks version meets minimum requirements (${MINIMUM_VERSION})`, async () => {
const subscriptionNext = jest.fn();
const result = observeIsPlaybooksEnabled(operator.database);
result.subscribe({next: subscriptionNext});
// Subscription always returns the first value
expect(subscriptionNext).toHaveBeenCalledWith(false);
subscriptionNext.mockClear();
await operator.handleSystem({
systems: [{id: SYSTEM_IDENTIFIERS.PLAYBOOKS_VERSION, value: MINIMUM_VERSION}],
prepareRecordsOnly: false,
});
expect(subscriptionNext).toHaveBeenCalledWith(true);
});
it('should return true when playbooks version has higher major version', async () => {
const subscriptionNext = jest.fn();
const higherVersion = `${MINIMUM_MAJOR_VERSION + 1}.0.0`;
const result = observeIsPlaybooksEnabled(operator.database);
result.subscribe({next: subscriptionNext});
// Subscription always returns the first value
expect(subscriptionNext).toHaveBeenCalledWith(false);
subscriptionNext.mockClear();
await operator.handleSystem({
systems: [{id: SYSTEM_IDENTIFIERS.PLAYBOOKS_VERSION, value: higherVersion}],
prepareRecordsOnly: false,
});
expect(subscriptionNext).toHaveBeenCalledWith(true);
});
it('should return true when playbooks version has higher minor version', async () => {
const subscriptionNext = jest.fn();
const higherVersion = `${MINIMUM_MAJOR_VERSION}.${MINIMUM_MINOR_VERSION + 1}.${MINIMUM_PATCH_VERSION}`;
const result = observeIsPlaybooksEnabled(operator.database);
result.subscribe({next: subscriptionNext});
// Subscription always returns the first value
expect(subscriptionNext).toHaveBeenCalledWith(false);
subscriptionNext.mockClear();
await operator.handleSystem({
systems: [{id: SYSTEM_IDENTIFIERS.PLAYBOOKS_VERSION, value: higherVersion}],
prepareRecordsOnly: false,
});
expect(subscriptionNext).toHaveBeenCalledWith(true);
});
it('should return true when playbooks version has higher patch version', async () => {
const subscriptionNext = jest.fn();
const higherVersion = `${MINIMUM_MAJOR_VERSION}.${MINIMUM_MINOR_VERSION}.${MINIMUM_PATCH_VERSION + 1}`;
const result = observeIsPlaybooksEnabled(operator.database);
result.subscribe({next: subscriptionNext});
// Subscription always returns the first value
expect(subscriptionNext).toHaveBeenCalledWith(false);
subscriptionNext.mockClear();
await operator.handleSystem({
systems: [{id: SYSTEM_IDENTIFIERS.PLAYBOOKS_VERSION, value: higherVersion}],
prepareRecordsOnly: false,
});
expect(subscriptionNext).toHaveBeenCalledWith(true);
});
it('should handle empty version string', async () => {
const subscriptionNext = jest.fn();
const result = observeIsPlaybooksEnabled(operator.database);
result.subscribe({next: subscriptionNext});
// Subscription always returns the first value
expect(subscriptionNext).toHaveBeenCalledWith(false);
subscriptionNext.mockClear();
await operator.handleSystem({
systems: [{id: SYSTEM_IDENTIFIERS.PLAYBOOKS_VERSION, value: ''}],
prepareRecordsOnly: false,
});
expect(subscriptionNext).toHaveBeenCalledWith(false);
});
it('should react to version changes', async () => {
const subscriptionNext = jest.fn();
const result = observeIsPlaybooksEnabled(operator.database);
result.subscribe({next: subscriptionNext});
// Subscription always returns the first value
expect(subscriptionNext).toHaveBeenCalledWith(false);
subscriptionNext.mockClear();
const aboveVersion = `${MINIMUM_MAJOR_VERSION + 1}.${MINIMUM_MINOR_VERSION + 1}.${MINIMUM_PATCH_VERSION + 1}`;
const belowVersion = `${MINIMUM_MAJOR_VERSION - 1}.${MINIMUM_MINOR_VERSION}.${MINIMUM_PATCH_VERSION}`;
// Update the version
await operator.handleSystem({
systems: [{id: SYSTEM_IDENTIFIERS.PLAYBOOKS_VERSION, value: aboveVersion}],
prepareRecordsOnly: false,
});
expect(subscriptionNext).toHaveBeenCalledWith(true);
subscriptionNext.mockClear();
// Update to a lower version
await operator.handleSystem({
systems: [{id: SYSTEM_IDENTIFIERS.PLAYBOOKS_VERSION, value: belowVersion}],
prepareRecordsOnly: false,
});
expect(subscriptionNext).toHaveBeenCalledWith(false);
});
});
});

View file

@ -0,0 +1,27 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Q, type Database} from '@nozbe/watermelondb';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {SYSTEM_IDENTIFIERS, MM_TABLES} from '@constants/database';
import {MINIMUM_MAJOR_VERSION, MINIMUM_MINOR_VERSION, MINIMUM_PATCH_VERSION} from '@playbooks/constants/version';
import {isMinimumServerVersion} from '@utils/helpers';
import type SystemModel from '@typings/database/models/servers/system';
export function observeIsPlaybooksEnabled(database: Database) {
return database.get<SystemModel>(MM_TABLES.SERVER.SYSTEM).query(
Q.where('id', SYSTEM_IDENTIFIERS.PLAYBOOKS_VERSION),
).observeWithColumns(['value']).pipe(
switchMap((systems) => {
const version = systems[0]?.value;
if (!version) {
return of$(false);
}
return of$(isMinimumServerVersion(version, MINIMUM_MAJOR_VERSION, MINIMUM_MINOR_VERSION, MINIMUM_PATCH_VERSION));
}),
);
}

View file

@ -0,0 +1,6 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export {default as PlaybookRunSchema} from './playbook_run';
export {default as PlaybookChecklistSchema} from './playbook_checklist';
export {default as PlaybookChecklistItemSchema} from './playbook_checklist_item';

View file

@ -0,0 +1,20 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {tableSchema} from '@nozbe/watermelondb';
import {PLAYBOOK_TABLES} from '@playbooks/constants/database';
const {PLAYBOOK_CHECKLIST} = PLAYBOOK_TABLES;
export default tableSchema({
name: PLAYBOOK_CHECKLIST,
columns: [
{name: 'run_id', type: 'string', isIndexed: true},
{name: 'title', type: 'string'},
{name: 'sync', type: 'string', isIndexed: true, isOptional: true},
{name: 'last_sync_at', type: 'number', isOptional: true},
{name: 'items_order', type: 'string'}, // JSON string
{name: 'update_at', type: 'number'},
],
});

Some files were not shown because too many files have changed in this diff Show more